Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

3751 lines
150 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""
Paged SSD Cache Manager for oMLX KV cache.
This module implements SSD-based storage for paged KV cache blocks,
enabling larger effective cache sizes than GPU memory allows.
Key features:
- Block-level safetensors serialization (compatible with mlx-lm)
- Hash-based subdirectory structure for scalability
- LRU-based paged SSD cache size management
- Startup scan to reuse existing cache files
Reference: mlx-lm/mlx_lm/models/cache.py (save_prompt_cache, load_prompt_cache)
"""
from __future__ import annotations
import contextlib
import errno
import json
import logging
import os
import queue
import shutil
import struct
import threading
import time
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from omlx.utils.formatting import format_bytes
from .interface import CacheManager
from .stats import PagedSSDCacheStats
logger = logging.getLogger(__name__)
# Check for MLX
try:
import mlx.core as mx
from mlx.utils import tree_flatten, tree_unflatten
HAS_MLX = True
except ImportError:
HAS_MLX = False
mx = None
# --- Async I/O constants ---
# Fraction of host RAM the pending-write queue targets at saturation.
# The queue holds raw-byte copies of KV blocks that the background
# writer hasn't drained yet (see ``_extract_tensor_bytes`` in
# ``save_block``). The hard fraction below bounds the soft floor so
# large-block workloads cannot silently reserve an unsafe amount of RAM.
_PENDING_WRITES_TARGET_RAM_FRACTION = 0.10
_PENDING_WRITES_HARD_RAM_FRACTION = 0.30
_PENDING_WRITES_SOFT_FLOOR = 32
_PENDING_WRITES_CEILING = 256
_PENDING_WRITE_PUT_TIMEOUT_SECONDS = 1.0
# Conservative defaults for the per-block cost estimator. The actual
# bytes-per-block depends on the model (num_layers × num_kv_heads ×
# head_dim × dtype_size × block_size_tokens × 2). At construction time
# the PagedSSDCacheManager doesn't always know these — see __init__'s
# ``expected_kv_bytes_per_token`` parameter — so the module-level
# default targets a 35B-class bf16 model whose per-token KV is ≈200 KB
# spread across all layers. Smaller models will be over-conservative
# (fine), larger models or larger blocks should pass an explicit value.
_DEFAULT_BLOCK_SIZE_TOKENS = 256
_DEFAULT_KV_BYTES_PER_TOKEN = 200_000
def _compute_max_pending_writes(
block_size_tokens: int = _DEFAULT_BLOCK_SIZE_TOKENS,
kv_bytes_per_token: int = _DEFAULT_KV_BYTES_PER_TOKEN,
target_fraction: float = _PENDING_WRITES_TARGET_RAM_FRACTION,
hard_fraction: float = _PENDING_WRITES_HARD_RAM_FRACTION,
) -> int:
"""Compute max pending writes queue depth.
Scales by *block bytes* so the target pending pool stays near
``target_fraction`` of host RAM regardless of how big each block is,
while ``hard_fraction`` bounds the soft floor:
worst_case_bytes = cap × block_size_tokens × kv_bytes_per_token
cap = (total_ram × target_fraction) / (block_size × kv_bytes_per_token)
Bounded by a soft floor, a byte hard cap, and a ceiling:
- Soft floor at 32 so even small systems with large blocks retain
burst headroom for a few in-flight writes — dropping to zero
means every save serializes against the disk and the writer
thread becomes a hard bottleneck on the inference loop.
- Hard cap at 30% of host RAM so the soft floor cannot turn very
large blocks into an unsafe memory reservation.
- Ceiling at 256 so 512 GB+ systems don't pin gigabytes against
a writer that's already keeping up at lower caps.
Workload sizing: long-context coding workloads snapshot ~73 blocks
per turn at 150 K tokens (block_size=2048), and at the default
block size of 256 tokens that's ~586 blocks per snapshot. The
queue is a *burst ceiling*, not steady state — a healthy writer
drains it continuously and the cap only matters when the writer
is fighting memory pressure or slow disk. Sustained saturation now
falls back to inline writes, so the cap controls when save latency
moves onto the request path.
Defaults target a 35B-class bf16 model at the default
``paged_cache_block_size=256``; pass an explicit
``kv_bytes_per_token`` for larger models or quantized configs.
"""
try:
total_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
block_bytes = max(1, block_size_tokens * kv_bytes_per_token)
target = int(total_bytes * target_fraction / block_bytes)
hard_cap = max(1, int(total_bytes * hard_fraction / block_bytes))
soft_target = max(_PENDING_WRITES_SOFT_FLOOR, target)
return max(1, min(_PENDING_WRITES_CEILING, soft_target, hard_cap))
except (ValueError, OSError):
return 64 # Safe default
# Module-level constant for static callers that don't have model
# config. The PagedSSDCacheManager recomputes per-instance from its
# ``__init__`` parameters so a non-default block size or model
# generation can plumb through.
_MAX_PENDING_WRITES = _compute_max_pending_writes()
# Cap on the number of LRU blocks ``_enforce_size_limit_for_new_block`` is
# allowed to unlink in one inline burst. Eviction normally returns ~1
# entry; the cap exists for the ENOSPC-recovery path where the disk-usage
# cache invalidates and the next ``_get_effective_max_size`` call can
# shrink sharply — ``evict_until_size`` would then return hundreds of
# entries at once and stall the inference thread on a syscall storm.
# Deferred-but-not-unlinked entries are reinserted into the index so
# subsequent saves drain the remainder; bounds per-call latency at the
# cost of taking multiple saves to fully reconverge.
_MAX_INLINE_UNLINKS_PER_SAVE = 32
# Cache format version. Bump when on-disk layout or RotatingKVCache meta_state
# semantics change in a way that older blocks become unsafe to load.
#
# Version "2": added with the mlx-lm 0.31.3 contract fix (issues #934 / #903).
# Version "1" / unset: pre-fix blocks. RotatingKVCache layers may have been
# zero-padded to max_size, which after the fix would leak zero positions
# into attention. Treat such blocks as a cache miss instead of migrating.
_CACHE_FORMAT_VERSION = "3"
# Versions whose blocks the current code can read. V3 polyfills V2 blocks
# whose layer data was stored as the legacy 2-tuple `(keys, values)` —
# they are upgraded to N-tuple markers on read so the rest of omlx core
# sees a uniform shape. New writes always use V3.
_READABLE_CACHE_FORMAT_VERSIONS = frozenset({"2", "3"})
# Layer cache type names whose meta_state should be clamped on save so the
# rotating buffer's _idx never exceeds the actual buffer length. Restoring a
# cache where _idx > keys.shape[2] makes BatchRotatingKVCache.merge() either
# overshoot the RHS or (when omlx pads) leak zero positions into attention.
_ROTATING_CACHE_TYPES = (
"RotatingKVCache",
"BatchRotatingKVCache",
"BufferedRotatingKVCache",
)
_STORAGE_CLASS_NAME_ALIASES = {
# mlx-vlm MTP uses this transient target-cache wrapper for rollback
# slack. Persist the canonical rotating-cache type so blocks remain
# compatible with the model.make_cache() signature.
"BufferedRotatingKVCache": "RotatingKVCache",
}
def _storage_layer_cache_types(
layer_cache_types: list[str] | tuple[str, ...] | None,
) -> list[str] | None:
"""Return type names to persist in new cache block metadata."""
if layer_cache_types is None:
return None
return [
_STORAGE_CLASS_NAME_ALIASES.get(cache_type, cache_type)
for cache_type in layer_cache_types
]
def _canonicalize_layer_cache_types(
layer_cache_types: list[str] | tuple[str, ...] | None,
) -> list[str] | None:
"""Normalize wrapper class names for metadata compatibility checks.
Wrapper classes that keep the same tensor representation compare equal.
Types that change tensor representation (e.g., ``TurboQuantKVCache`` vs
``KVCache``) are NOT collapsed -- that mismatch is real and the block must
be invalidated.
"""
if layer_cache_types is None:
return None
wrapper_to_canonical = {
"SizedArraysCache": "ArraysCache",
"PrefillReadyRotatingKVCache": "RotatingKVCache",
# Batch and single-request TurboQuant caches persist the same packed
# per-request state (the save path records whichever class name it
# extracted; the restore path rebuilds a TurboQuantKVCache from
# either). Collapsing them keeps the predicted layout from
# refresh_ssd_layer_signature — which always says
# "TurboQuantKVCache" — from sweeping valid batch-form blocks.
"BatchTurboQuantKVCache": "TurboQuantKVCache",
}
return [
wrapper_to_canonical.get(cache_type, cache_type)
for cache_type in layer_cache_types
]
def _cache_compat_signature(
*,
model_name: str = "",
num_layers: int = 0,
block_size: int = 0,
layer_cache_types: list[str] | None = None,
turboquant_kv_bits: float | None = None,
) -> str:
"""Return a stable compatibility signature for a persisted cache block."""
payload = {
"model_name": model_name or "",
"num_layers": int(num_layers or 0),
"block_size": int(block_size or 0),
"layer_cache_types": list(layer_cache_types or []),
}
# TurboQuant packed state width depends on the bit depth
# (packed_width = ceil(head_dim * bits / 32)), so blocks written at
# different bit depths are shape-incompatible (#2045). Only stamped
# when TurboQuant is active so non-TurboQuant signatures stay
# byte-identical to the previous format.
if turboquant_kv_bits is not None:
payload["turboquant_kv_bits"] = float(turboquant_kv_bits)
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
def _signature_turboquant_bits(cache_signature: str) -> float | None:
"""Extract ``turboquant_kv_bits`` from a stored signature, or None."""
if not cache_signature:
return None
try:
payload = json.loads(cache_signature)
except (TypeError, ValueError):
return None
if not isinstance(payload, dict):
# Corrupted/foreign signature that parses as a JSON scalar or list.
# Report "no recorded depth" instead of raising: an AttributeError
# here would abort the whole stale-signature sweep.
return None
bits = payload.get("turboquant_kv_bits")
if bits is None:
return None
try:
return float(bits)
except (TypeError, ValueError):
return None
def _block_turboquant_bits(
layer_cache_types: list[str] | None,
layer_meta_states: list[tuple] | None,
) -> float | None:
"""Read the bit depth a block's own TurboQuant layers were packed at.
TurboQuant meta_state is ``(offset, bits, seed, ...)`` — the same tuple
the restore path reads back at reconstruction. Deriving the signature
stamp from the block itself keeps it truthful even when the manager's
expectation is stale or not yet learned.
"""
if not layer_cache_types or not layer_meta_states:
return None
for i, cache_type in enumerate(layer_cache_types):
if cache_type not in ("TurboQuantKVCache", "BatchTurboQuantKVCache"):
continue
if i >= len(layer_meta_states):
continue
meta_state = layer_meta_states[i]
if isinstance(meta_state, (list, tuple)) and len(meta_state) >= 3:
try:
return float(meta_state[1])
except (TypeError, ValueError):
return None
return None
def _clamp_rotating_meta_states(
cache_data: list[Any],
layer_cache_types: list[str] | None,
layer_meta_states: list[tuple] | None,
) -> list[tuple] | None:
"""Clamp ``_idx`` to ``keys.shape[2]`` for RotatingKVCache layers.
RotatingKVCache.meta_state is ``(keep, max_size, offset, _idx)``. When
we save a snapshot, ``_idx`` must reflect the actual buffer length so
the restored cache lands in case 1 of ``_temporal_order``. Older code
paths could leave ``_idx == max_size`` after zero-padding the buffer;
by clamping at write time we ensure newer blocks are always safe to
restore.
"""
if not layer_meta_states or not layer_cache_types:
return layer_meta_states
clamped: list[tuple] = []
for i, meta in enumerate(layer_meta_states):
if (
i < len(layer_cache_types)
and layer_cache_types[i] in _ROTATING_CACHE_TYPES
and meta
and len(meta) >= 4
and i < len(cache_data)
):
layer_data = cache_data[i]
seq_len: int | None = None
if (
isinstance(layer_data, tuple)
and len(layer_data) == 2
and not (
isinstance(layer_data[0], str) and layer_data[0].startswith("__")
)
):
keys = layer_data[0]
if hasattr(keys, "shape") and len(keys.shape) >= 3:
seq_len = int(keys.shape[2])
if seq_len is not None:
try:
keep, max_size, offset, idx = meta[:4]
idx_int = int(idx)
if idx_int > seq_len:
clamped.append((keep, max_size, offset, str(seq_len)))
continue
except (TypeError, ValueError):
pass
clamped.append(meta)
return clamped
def _has_zero_dim(tensor: Any) -> bool:
"""Check if a tensor has any zero-dimension axis (unsupported by safetensors)."""
return hasattr(tensor, "shape") and any(d == 0 for d in tensor.shape)
def _encode_shape(shape) -> str:
"""Encode tensor shape as comma-separated string for safetensors metadata."""
return ",".join(str(d) for d in shape)
def _decode_shape(shape_str: str) -> tuple:
"""Decode shape string back to tuple of ints."""
return tuple(int(d) for d in shape_str.split(","))
# --- Safetensors dtype mapping for background-thread-safe serialization ---
# These mappings enable writing safetensors files without any mx/Metal API,
# bypassing the bfloat16 limitation that blocked PR #16 v2 (numpy doesn't
# support bfloat16, but safetensors format natively does via "BF16" dtype).
_MX_TO_ST_DTYPE: dict[Any, str] = {}
_ST_TO_MX_DTYPE: dict[str, Any] = {}
_ST_DTYPE_TO_NP: dict[str, Any] = {}
if HAS_MLX:
_MX_TO_ST_DTYPE = {
mx.float16: "F16",
mx.float32: "F32",
mx.bfloat16: "BF16",
mx.int8: "I8",
mx.int16: "I16",
mx.int32: "I32",
mx.int64: "I64",
mx.uint8: "U8",
mx.uint16: "U16",
mx.uint32: "U32",
mx.uint64: "U64",
mx.bool_: "BOOL",
}
_ST_TO_MX_DTYPE = {v: k for k, v in _MX_TO_ST_DTYPE.items()}
_ST_DTYPE_TO_NP = {
"F16": np.float16,
"F32": np.float32,
"BF16": np.uint16, # bfloat16 handled via uint16 view
"I8": np.int8,
"I16": np.int16,
"I32": np.int32,
"I64": np.int64,
"U8": np.uint8,
"U16": np.uint16,
"U32": np.uint32,
"U64": np.uint64,
"BOOL": np.bool_,
}
def _extract_tensor_bytes(arr: mx.array) -> tuple[bytes, str, list[int]]:
"""Extract raw bytes from an mx.array.
Materialize the array at this last-mile boundary before touching the
Python buffer protocol. ``store_cache`` may create lazy block slices,
clones, or placeholder arrays after scheduler-side pre-eval collection,
and ``memoryview(arr)`` would otherwise trigger an implicit eval from the
background cache-store worker thread.
For bfloat16 arrays, uses view(uint16) since the buffer protocol does
not support bfloat16 directly. Materialize the view as well so the raw
buffer read never becomes an implicit MLX eval.
Args:
arr: MLX array to serialize.
Returns:
Tuple of (raw_bytes, safetensors_dtype_string, shape_list).
"""
mx.eval(arr)
dtype_str = _MX_TO_ST_DTYPE[arr.dtype]
shape = list(arr.shape)
if arr.dtype == mx.bfloat16:
u16 = arr.view(mx.uint16)
mx.eval(u16)
raw = bytes(memoryview(u16))
else:
raw = bytes(memoryview(arr))
return raw, dtype_str, shape
def _restore_tensor_from_bytes(
raw: bytes, dtype_str: str, shape: list[int]
) -> mx.array:
"""Restore an mx.array from raw bytes extracted by _extract_tensor_bytes.
No Metal API required — uses numpy as intermediary.
Args:
raw: Raw tensor bytes.
dtype_str: Safetensors dtype string (e.g., "F16", "BF16").
shape: Tensor shape as list of ints.
Returns:
Restored mx.array with correct dtype and shape.
"""
np_dtype = _ST_DTYPE_TO_NP[dtype_str]
np_arr = np.frombuffer(raw, dtype=np_dtype)
arr = mx.array(np_arr)
if dtype_str == "BF16":
arr = arr.view(mx.bfloat16)
return arr.reshape(shape)
def _write_safetensors_no_mx(
path: str,
tensors_raw: dict[str, tuple[bytes, str, list[int]]],
metadata: dict[str, str] | None = None,
) -> int:
"""Write a safetensors file without any mx/Metal API calls.
Safe to call from background threads. Produces files fully compatible
with mx.load(path, return_metadata=True).
The safetensors binary format:
[8 bytes: header_size as little-endian uint64]
[header_size bytes: JSON header]
[remaining bytes: concatenated tensor data]
Args:
path: Output file path (must include .safetensors extension).
tensors_raw: Dict of {name: (raw_bytes, dtype_str, shape)}.
metadata: Optional string-to-string metadata dict.
Returns:
Total file size in bytes.
"""
offset = 0
header_tensors = {}
all_data = []
for name, (raw, dtype_str, shape) in tensors_raw.items():
header_tensors[name] = {
"dtype": dtype_str,
"shape": shape,
"data_offsets": [offset, offset + len(raw)],
}
all_data.append(raw)
offset += len(raw)
header_dict = dict(header_tensors)
if metadata:
header_dict["__metadata__"] = metadata
header_json = json.dumps(header_dict, separators=(",", ":")).encode("utf-8")
# Safetensors spec: header must be 8-byte aligned
pad = (8 - len(header_json) % 8) % 8
header_json += b" " * pad
with open(path, "wb") as f:
f.write(struct.pack("<Q", len(header_json)))
f.write(header_json)
for d in all_data:
f.write(d)
return 8 + len(header_json) + offset
def parse_size(size_str: str) -> int:
"""
Parse a human-readable size string to bytes.
Args:
size_str: Size string like "100GB", "50MB", "1TB"
Returns:
Size in bytes.
"""
size_str = size_str.strip().upper()
units = {
"B": 1,
"KB": 1024,
"MB": 1024**2,
"GB": 1024**3,
"TB": 1024**4,
}
for unit, multiplier in units.items():
if size_str.endswith(unit):
try:
value = float(size_str[: -len(unit)])
return int(value * multiplier)
except ValueError:
pass
# Try parsing as plain number (bytes)
try:
return int(size_str)
except ValueError:
raise ValueError(f"Invalid size string: {size_str}")
@dataclass
class PagedSSDBlockMetadata:
"""
Metadata for a block stored on SSD.
Attributes:
block_hash: Content hash (SHA256) for identification
file_path: Full path to safetensors file
file_size: Size in bytes
token_count: Number of tokens in this block
created_at: Timestamp when saved
last_access: Last access time for LRU tracking
num_layers: Number of model layers
model_name: Model name for cache isolation between different models
block_size: Paged cache block size that created this block
cache_signature: Compatibility signature for the saved cache layout
layer_cache_types: Per-layer cache type names (e.g., ["KVCache", "ArraysCache"])
layer_meta_states: Per-layer meta_state tuples for reconstruction
"""
block_hash: bytes
file_path: Path
file_size: int
token_count: int
created_at: float
last_access: float
num_layers: int
model_name: str = ""
block_size: int = 0
cache_signature: str = ""
layer_cache_types: list[str] | None = None
layer_meta_states: list[tuple] | None = None
def touch(self) -> None:
"""Update last access time."""
self.last_access = time.time()
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"block_hash": self.block_hash.hex(),
"file_path": str(self.file_path),
"file_size": self.file_size,
"token_count": self.token_count,
"created_at": self.created_at,
"last_access": self.last_access,
"num_layers": self.num_layers,
"model_name": self.model_name,
"block_size": self.block_size,
"cache_signature": self.cache_signature,
}
if self.layer_cache_types:
result["layer_cache_types"] = self.layer_cache_types
if self.layer_meta_states:
# Convert tuples to lists for JSON serialization
result["layer_meta_states"] = [list(m) for m in self.layer_meta_states]
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PagedSSDBlockMetadata:
"""Create from dictionary."""
# Parse layer_meta_states back to tuples
layer_meta_states = None
if "layer_meta_states" in data and data["layer_meta_states"]:
layer_meta_states = [tuple(m) for m in data["layer_meta_states"]]
return cls(
block_hash=bytes.fromhex(data["block_hash"]),
file_path=Path(data["file_path"]),
file_size=data["file_size"],
token_count=data["token_count"],
created_at=data["created_at"],
last_access=data["last_access"],
num_layers=data["num_layers"],
model_name=data.get("model_name", ""),
block_size=data.get("block_size", 0),
cache_signature=data.get("cache_signature", ""),
layer_cache_types=data.get("layer_cache_types"),
layer_meta_states=layer_meta_states,
)
class PagedSSDCacheIndex:
"""
In-memory index of SSD cache files.
Provides O(1) lookup by block_hash and LRU tracking for size management.
Thread-safe for concurrent access.
"""
def __init__(self, max_size_bytes: int):
"""
Initialize the SSD cache index.
Args:
max_size_bytes: Maximum total size of SSD cache files.
"""
self._index: dict[bytes, PagedSSDBlockMetadata] = {}
self._lru: OrderedDict[bytes, float] = OrderedDict()
self._total_size: int = 0
self._max_size: int = max_size_bytes
self._lock = threading.RLock()
def add(self, metadata: PagedSSDBlockMetadata) -> None:
"""
Add a block to the index.
Args:
metadata: Block metadata to add.
"""
with self._lock:
# Remove existing entry if present
if metadata.block_hash in self._index:
old_meta = self._index[metadata.block_hash]
self._total_size -= old_meta.file_size
del self._lru[metadata.block_hash]
self._index[metadata.block_hash] = metadata
self._lru[metadata.block_hash] = metadata.last_access
self._total_size += metadata.file_size
def sort_lru_by_last_access(self) -> None:
"""Restore LRU ordering from each entry's last access timestamp."""
with self._lock:
self._lru = OrderedDict(
sorted(
(
(block_hash, self._index[block_hash].last_access)
for block_hash in self._lru
if block_hash in self._index
),
key=lambda item: item[1],
)
)
def get(self, block_hash: bytes) -> PagedSSDBlockMetadata | None:
"""
Get block metadata by hash.
Args:
block_hash: Block content hash.
Returns:
PagedSSDBlockMetadata if found, None otherwise.
"""
with self._lock:
return self._index.get(block_hash)
def remove(self, block_hash: bytes) -> PagedSSDBlockMetadata | None:
"""
Remove a block from the index.
Args:
block_hash: Block content hash.
Returns:
Removed metadata if found, None otherwise.
"""
with self._lock:
if block_hash not in self._index:
return None
metadata = self._index.pop(block_hash)
del self._lru[block_hash]
self._total_size -= metadata.file_size
return metadata
def touch(self, block_hash: bytes) -> None:
"""
Update last access time (move to end of LRU).
Args:
block_hash: Block content hash.
"""
with self._lock:
if block_hash in self._index:
self._index[block_hash].touch()
self._lru.move_to_end(block_hash)
self._lru[block_hash] = self._index[block_hash].last_access
def get_lru_entries(self, count: int) -> list[PagedSSDBlockMetadata]:
"""
Get least recently used entries.
Args:
count: Maximum number of entries to return.
Returns:
List of LRU metadata entries.
"""
with self._lock:
result = []
for block_hash in list(self._lru.keys())[:count]:
if block_hash in self._index:
result.append(self._index[block_hash])
return result
def evict_until_size(
self,
target_size: int,
max_count: int | None = None,
) -> list[PagedSSDBlockMetadata]:
"""
Evict LRU entries until total size is below target.
Args:
target_size: Target total size in bytes.
max_count: Optional cap on the number of entries removed in
one call. When the cap is hit before ``total_size`` drops
below ``target_size`` the call returns the partial slice
and leaves the remaining LRU entries in the index; the
caller is expected to retry on the next save. The cap is
pushed down here (rather than the caller popping a
surplus and reinserting it) so the index never exposes a
transient "evicted but not yet unlinked" gap that a
concurrent writer's ``contains()`` check could observe
as a deleted block.
Returns:
List of evicted metadata (files need to be deleted by caller).
Note:
Loop termination depends on ``remove()`` decrementing
``_total_size`` for every popped entry. If a future refactor
moves the decrement to "after the on-disk unlink succeeds",
this loop must also gain a "skip entries already pulled this
pass" guard or it can spin forever when unlinks fail.
"""
with self._lock:
evicted = []
while self._total_size > target_size and self._lru:
if max_count is not None and len(evicted) >= max_count:
break
# Get LRU entry (first in OrderedDict)
block_hash = next(iter(self._lru))
metadata = self.remove(block_hash)
if metadata:
evicted.append(metadata)
return evicted
def contains(self, block_hash: bytes) -> bool:
"""Check if block exists in index."""
with self._lock:
return block_hash in self._index
@property
def total_size(self) -> int:
"""Get total size of indexed files."""
with self._lock:
return self._total_size
@property
def max_size(self) -> int:
"""Get maximum allowed size."""
return self._max_size
@property
def count(self) -> int:
"""Get number of indexed blocks."""
with self._lock:
return len(self._index)
def update_file_size(self, block_hash: bytes, actual_size: int) -> None:
"""Update file size for a block after background write completes.
Args:
block_hash: Block content hash.
actual_size: Actual file size in bytes.
"""
with self._lock:
entry = self._index.get(block_hash)
if entry is not None:
self._total_size += actual_size - entry.file_size
entry.file_size = actual_size
def get_all_hashes(self) -> list[bytes]:
"""Get all indexed block hashes."""
with self._lock:
return list(self._index.keys())
def get_all_metadata(self) -> list[PagedSSDBlockMetadata]:
"""Get a snapshot of all indexed block metadata."""
with self._lock:
return list(self._index.values())
@dataclass
class _HotCacheBudgetEntry:
owner: Any
block_hash: bytes
size_bytes: int
class SharedHotCacheBudget:
"""Process-wide byte budget for hot cache entries across cache managers."""
def __init__(self, max_bytes: int):
self.max_bytes = max(0, int(max_bytes))
self._entries: OrderedDict[tuple[int, bytes], _HotCacheBudgetEntry] = (
OrderedDict()
)
self._total_bytes = 0
self._lock = threading.RLock()
@staticmethod
def _key(owner: Any, block_hash: bytes) -> tuple[int, bytes]:
return (id(owner), block_hash)
@property
def total_bytes(self) -> int:
with self._lock:
return self._total_bytes
@property
def remaining_bytes(self) -> int:
with self._lock:
return max(0, self.max_bytes - self._total_bytes)
def touch(self, owner: Any, block_hash: bytes) -> None:
"""Mark an entry as recently used in the global LRU order."""
with self._lock:
key = self._key(owner, block_hash)
if key in self._entries:
self._entries.move_to_end(key)
def forget(self, owner: Any, block_hash: bytes) -> None:
"""Remove one entry from budget accounting if present."""
with self._lock:
key = self._key(owner, block_hash)
entry = self._entries.pop(key, None)
if entry is not None:
self._total_bytes = max(0, self._total_bytes - entry.size_bytes)
def forget_owner(self, owner: Any) -> None:
"""Remove all entries owned by a cache manager."""
owner_id = id(owner)
with self._lock:
keys = [key for key in self._entries if key[0] == owner_id]
for key in keys:
entry = self._entries.pop(key)
self._total_bytes = max(0, self._total_bytes - entry.size_bytes)
def clear_all_owners(self) -> int:
"""Clear the hot cache of every manager the budget still references.
The budget keeps a strong reference to each owning manager, so a
manager orphaned by an abnormal teardown stays reachable here even
when it is no longer attached to a loaded scheduler. Snapshot the
owners under the lock, then clear outside it (clear_hot_cache calls
forget_owner, which re-takes the lock).
"""
with self._lock:
owners = []
seen = set()
for entry in self._entries.values():
if id(entry.owner) not in seen:
seen.add(id(entry.owner))
owners.append(entry.owner)
cleared = 0
for owner in owners:
fn = getattr(owner, "clear_hot_cache", None)
if callable(fn):
try:
cleared += fn()
except Exception:
# Keep going for the other owners, but do not hide the
# failure: a silently-swallowed error makes admin recovery
# look successful while memory is still pinned.
logger.warning(
"clear_hot_cache failed for an orphaned owner",
exc_info=True,
)
return cleared
def shrink_to(
self,
target_bytes: int,
protected_hashes: set[bytes] | None = None,
) -> int:
"""Shrink the shared hot cache to ``target_bytes`` by global LRU order.
Returns the budgeted bytes removed from hot-cache ownership. Dirty
evictions are handed back to each owner so the existing SSD write-through
path can preserve them.
"""
target_bytes = max(0, int(target_bytes))
protected_hashes = protected_hashes or set()
victims: list[tuple[Any, bytes, int]] = []
with self._lock:
while self._total_bytes > target_bytes and self._entries:
victim_key = None
victim = None
for key, candidate in self._entries.items():
if candidate.block_hash not in protected_hashes:
victim_key = key
victim = candidate
break
if victim_key is None or victim is None:
break
self._entries.pop(victim_key)
self._total_bytes = max(0, self._total_bytes - victim.size_bytes)
victims.append((victim.owner, victim.block_hash, victim.size_bytes))
freed = 0
for owner, block_hash, size_bytes in victims:
evicted = owner._hot_cache_remove(block_hash, update_budget=False)
if evicted is not None:
freed += size_bytes
owner._handle_hot_cache_eviction(block_hash, evicted)
return freed
def put(
self, owner: Any, block_hash: bytes, size_bytes: int
) -> list[tuple[Any, bytes]]:
"""Account an entry and return globally-evicted owners/block hashes."""
victims: list[tuple[Any, bytes]] = []
size_bytes = max(0, int(size_bytes))
with self._lock:
key = self._key(owner, block_hash)
old = self._entries.pop(key, None)
if old is not None:
self._total_bytes = max(0, self._total_bytes - old.size_bytes)
self._entries[key] = _HotCacheBudgetEntry(
owner=owner,
block_hash=block_hash,
size_bytes=size_bytes,
)
self._total_bytes += size_bytes
while self._total_bytes > self.max_bytes and self._entries:
victim_key, victim = self._entries.popitem(last=False)
if victim_key == key and not self._entries:
self._entries[victim_key] = victim
break
self._total_bytes = max(0, self._total_bytes - victim.size_bytes)
victims.append((victim.owner, victim.block_hash))
return victims
class PagedSSDCacheManager(CacheManager):
"""
Manages SSD storage for KV cache blocks.
Features:
- Block-level safetensors serialization
- Hash-based subdirectory structure (single level: /a/, /b/, etc.)
- LRU-based SSD cache size management
Implements the CacheManager ABC interface for consistency with other
cache implementations in oMLX.
Example:
>>> manager = PagedSSDCacheManager(
... cache_dir=Path("/tmp/ssd_cache"),
... max_size_bytes=100 * 1024**3, # 100GB
... )
>>> manager.save_block(block_hash, cache_data, token_count=64)
>>> loaded = manager.load_block(block_hash)
"""
# Subdirectory prefixes (hash first char)
SUBDIR_CHARS = "0123456789abcdef"
def __init__(
self,
cache_dir: Path | None,
max_size_bytes: int,
hot_cache_max_bytes: int = 0,
hot_cache_only: bool = False,
hot_cache_budget: SharedHotCacheBudget | None = None,
expected_model_name: str = "",
expected_num_layers: int = 0,
expected_block_size: int = 0,
expected_block_size_tokens: int = _DEFAULT_BLOCK_SIZE_TOKENS,
expected_kv_bytes_per_token: int = _DEFAULT_KV_BYTES_PER_TOKEN,
expected_layer_cache_types: list[str] | None = None,
):
"""
Initialize the SSD cache manager.
Args:
cache_dir: Directory for SSD cache files.
max_size_bytes: Maximum total size of SSD cache.
hot_cache_max_bytes: Maximum in-memory hot cache size in bytes.
0 means disabled (default).
hot_cache_only: When True, skip directory init and writer thread.
All data is stored exclusively in the hot cache (RAM only).
No SSD I/O is performed.
hot_cache_budget: Optional process-wide hot cache budget shared
by all loaded model cache managers.
expected_model_name: Current model name. Blocks saved for a
different model name are skipped at startup. Empty string
disables this check (backwards compatible).
expected_num_layers: Current cache-layer count. Blocks saved with
a different num_layers are skipped at startup. 0 disables this
check (backwards compatible). Catches stale blocks left over
after a model upgrade changes its effective layer count (e.g.,
#1404 attaching MTPModule changed 30 -> 40 layers).
expected_block_size: Current paged cache block size. Blocks saved
with another block size are skipped at startup. 0 disables this
check for backwards compatibility.
expected_block_size_tokens: Paged-cache block size in tokens used to
size the pending-writes queue. Separate from ``expected_block_size``
so the writer-queue formula keeps a real value (default 256)
even when the cache-compat check is disabled (0). Passing a
larger value shrinks the cap so small Macs with large blocks
don't pin gigabytes at saturation; passing a smaller value lets
the cap grow to give workloads with many tiny blocks enough
burst headroom.
expected_kv_bytes_per_token: Per-token KV byte estimate (all
layers, K + V, dtype). Together with ``expected_block_size_tokens``
this drives the bytes-aware queue cap. Defaults to a
35B-class bf16 estimate; pass an explicit value for
quantized models or unusually wide/narrow architectures.
expected_layer_cache_types: Optional current cache layout. When
provided, blocks with a different per-layer type list are
skipped at startup.
"""
self._cache_dir = cache_dir
self._max_size = max_size_bytes
self._index = PagedSSDCacheIndex(max_size_bytes)
self._incompatible_index = PagedSSDCacheIndex(max_size_bytes)
self._hot_cache_only = hot_cache_only
self._expected_model_name = expected_model_name
self._expected_num_layers = expected_num_layers
self._expected_block_size = expected_block_size
self._expected_layer_cache_types = expected_layer_cache_types
# TurboQuant bit depth requests will quantize at; learned together
# with the layer signature via ``set_expected_layer_signature``
# (the depth is only known once the engine has applied the model's
# TurboQuant settings, after this manager is constructed).
self._expected_turboquant_kv_bits: float | None = None
# Set once we have swept stale-signature blocks for the current
# ``_expected_layer_cache_types`` / ``_expected_turboquant_kv_bits``.
# Re-assigning the signature (e.g., via
# ``adopt_layer_signature_if_unset``) resets this so the new
# signature triggers its own one-shot sweep.
self._signature_sweep_completed = False
self._lock = threading.RLock()
# Disk usage cache for dynamic effective max size (30s TTL)
self._disk_usage_cache = None # type: shutil._ntuple_diskusage | None
self._disk_usage_cache_time: float = 0.0
self._last_disk_pressure_warn: float = 0.0
# Statistics
self._stats = {
"saves": 0,
"saves_persisted": 0,
"loads": 0,
"hits": 0,
"misses": 0,
"evictions": 0,
"evict_unlink_failures": 0,
"errors": 0,
"hot_cache_hits": 0,
"hot_cache_evictions": 0,
"hot_cache_promotions": 0,
"preload_calls": 0,
"preload_blocks_loaded": 0,
"preload_time_ms": 0.0,
"ssd_write_drops": 0,
"ssd_inline_write_fallbacks": 0,
}
# --- Hot cache (in-memory raw-bytes tier) ---
self._hot_cache_budget = hot_cache_budget
self._hot_cache_max_bytes = (
hot_cache_budget.max_bytes
if hot_cache_budget is not None
else hot_cache_max_bytes
)
self._hot_cache_enabled = self._hot_cache_max_bytes > 0
self._hot_cache: OrderedDict[bytes, dict] = OrderedDict()
self._hot_cache_total_bytes: int = 0
self._hot_cache_lock = threading.Lock()
# Initialize directory structure and scan existing files
# Skip in hot_cache_only mode: no SSD I/O, so no directories needed.
if self._cache_dir and not self._hot_cache_only:
self._init_directories()
self._scan_existing_files()
# --- Background writer for non-blocking saves ---
# Recompute the pending-writes cap from THIS cache's block/model
# parameters so a non-default block size shrinks (or grows) the
# cap appropriately. Falls back to the module-level constant
# when no override is supplied.
#
# Stash the inputs the constructor was called with so callers
# (and the plumbing-regression test) can verify what reached
# the manager without depending on the cap math landing in a
# particular floor/ceiling band on the test host.
self._expected_block_size_tokens = expected_block_size_tokens
self._expected_kv_bytes_per_token = expected_kv_bytes_per_token
self._max_pending_writes = _compute_max_pending_writes(
block_size_tokens=expected_block_size_tokens,
kv_bytes_per_token=expected_kv_bytes_per_token,
)
self._write_queue: queue.Queue = queue.Queue(maxsize=self._max_pending_writes)
# Track which block hashes are queued for background write
self._pending_write_hashes: set = set()
self._pending_write_hashes_lock = threading.Lock()
# Lock ordering invariant: _hot_cache_lock -> _pending_write_hashes_lock.
# Never acquire in reverse. Load path: _hot_cache_get (holds _hot_cache_lock,
# releases), then _pending_write_buffer_get (holds _pending_write_hashes_lock).
# Eviction path: _hot_cache_put (holds _hot_cache_lock, releases), then
# _enqueue_ssd_write (holds _pending_write_hashes_lock).
self._pending_write_buffers: dict[bytes, dict] = {}
self._writer_shutdown = threading.Event()
# Writer thread is only needed when writing to SSD.
self._writer_thread = None
if not self._hot_cache_only:
self._writer_thread = threading.Thread(
target=self._writer_loop,
name="ssd-cache-writer",
daemon=True,
)
self._writer_thread.start()
hot_info = ""
if self._hot_cache_enabled:
hot_info = f", hot_cache={format_bytes(hot_cache_max_bytes)}"
# Log initialization with disk space info
disk_info = ""
if self._cache_dir:
try:
du = shutil.disk_usage(self._cache_dir)
disk_info = (
f", disk_free={format_bytes(du.free)}, "
f"cache_used={format_bytes(self._tracked_ssd_size())}, "
f"incompatible_files={self._incompatible_index.count}"
)
except OSError:
pass
logger.info(
f"PagedSSDCacheManager initialized: dir={self._cache_dir}, "
f"max_size={format_bytes(max_size_bytes)}{hot_info}, "
f"existing_files={self._index.count}{disk_info}"
)
# --- Hot cache helpers ---
@staticmethod
def _hot_cache_entry_size(entry: dict) -> int:
"""Calculate memory footprint of a hot cache entry.
Entries from save_block() use 'tensors_raw' (raw bytes).
Entries from _promote_to_hot_cache() may use 'arrays' (mx.array objects
loaded from SSD, not from active inference — safe to retain).
"""
if "arrays" in entry:
return sum(arr.nbytes for arr in entry["arrays"].values())
if "tensors_raw" in entry:
return sum(len(raw) for raw, _, _ in entry["tensors_raw"].values())
return 0
def _effective_hot_cache_max_bytes(self) -> int:
if self._hot_cache_budget is not None:
return self._hot_cache_budget.max_bytes
return self._hot_cache_max_bytes
def _hot_cache_available_bytes(self) -> int:
if self._hot_cache_budget is not None:
return self._hot_cache_budget.remaining_bytes
return max(0, self._hot_cache_max_bytes - self._hot_cache_total_bytes)
def _handle_hot_cache_eviction(self, block_hash: bytes, entry: dict) -> None:
self._stats["hot_cache_evictions"] += 1
if not entry.get("dirty", True):
logger.debug(
"Evicted clean hot cache block %s; SSD copy already exists",
block_hash.hex()[:16],
)
return
self._enqueue_ssd_write(block_hash, entry)
def _hot_cache_put(self, block_hash: bytes, entry: dict) -> None:
"""Add entry to hot cache, evicting LRU entries if capacity exceeded.
Evicted entries are flushed to SSD via the background writer thread.
"""
entry_size = self._hot_cache_entry_size(entry)
evicted_entries: list = []
if self._hot_cache_budget is not None:
with self._hot_cache_lock:
old = self._hot_cache.pop(block_hash, None)
if old is not None:
self._hot_cache_total_bytes -= self._hot_cache_entry_size(old)
self._hot_cache[block_hash] = entry
self._hot_cache_total_bytes += entry_size
victims = self._hot_cache_budget.put(self, block_hash, entry_size)
for owner, victim_hash in victims:
evicted = owner._hot_cache_remove(victim_hash, update_budget=False)
if evicted is not None:
owner._handle_hot_cache_eviction(victim_hash, evicted)
return
with self._hot_cache_lock:
# Remove old entry if updating
if block_hash in self._hot_cache:
old = self._hot_cache.pop(block_hash)
self._hot_cache_total_bytes -= self._hot_cache_entry_size(old)
# Evict LRU entries until we have room
while (
self._hot_cache_total_bytes + entry_size > self._hot_cache_max_bytes
and self._hot_cache
):
evicted_hash, evicted = self._hot_cache.popitem(last=False)
self._hot_cache_total_bytes -= self._hot_cache_entry_size(evicted)
evicted_entries.append((evicted_hash, evicted))
self._hot_cache[block_hash] = entry
self._hot_cache_total_bytes += entry_size
# Flush evicted entries to SSD outside the hot cache lock
for evicted_hash, evicted in evicted_entries:
self._handle_hot_cache_eviction(evicted_hash, evicted)
def _enqueue_ssd_write(
self,
block_hash: bytes,
entry: dict,
*,
blocking: bool = False,
) -> bool:
"""Enqueue a hot cache entry for SSD background write.
Used when evicting from hot cache or flushing on shutdown.
Adds block to SSD index before enqueueing write.
All callers wait briefly for queue space. If saturation persists, the
caller writes inline so dirty hot-cache blocks are never dropped just
because the background writer is behind.
"""
if self._hot_cache_only:
return False
if not entry.get("dirty", True):
return True
blk_meta = entry.get("block_metadata")
if blk_meta is None:
return False
file_path = blk_meta.file_path
tensors_raw = entry.get("tensors_raw", {})
if not tensors_raw:
return False
metadata = entry["file_metadata"]
# 1. Buffer first — instant read-back for concurrent loads (CPD K1).
# Must precede _index.add so load_block never sees an index hit
# for a block that has no file and no buffer entry yet.
with self._pending_write_hashes_lock:
if block_hash in self._pending_write_buffers:
return True
self._pending_write_buffers[block_hash] = entry
self._pending_write_hashes.add(block_hash)
# 2. Index second — makes the block discoverable in has_block/contains.
if not self._index.contains(block_hash):
self._enforce_size_limit_for_new_block(blk_meta.file_size)
self._incompatible_index.remove(block_hash)
self._index.add(blk_meta)
# 3. Queue third — enqueue for background writer.
try:
item = (block_hash, tensors_raw, metadata, file_path)
# Non-blocking callers (hot-cache LRU spill) also wait so a
# transient writer backlog doesn't silently drop blocks. Blocking
# callers (shutdown flush) use the same bounded wait.
self._write_queue.put(item, timeout=_PENDING_WRITE_PUT_TIMEOUT_SECONDS)
logger.debug(
f"Evicted hot cache block to SSD write queue: "
f"{block_hash.hex()[:16]}..."
)
return True
except queue.Full:
self._stats["ssd_inline_write_fallbacks"] += 1
logger.warning(
f"SSD write queue saturated (cap={self._max_pending_writes}); "
f"writing evicted block {block_hash.hex()[:16]} inline"
)
ok = self._write_block_file(
block_hash,
tensors_raw,
metadata,
file_path,
source="inline-fallback",
)
self._clear_pending_write(block_hash)
return ok
def _hot_cache_get(self, block_hash: bytes) -> dict | None:
"""Get entry from hot cache, updating LRU order. Returns None on miss."""
with self._hot_cache_lock:
if block_hash in self._hot_cache:
self._hot_cache.move_to_end(block_hash)
entry = self._hot_cache[block_hash]
else:
return None
if self._hot_cache_budget is not None:
self._hot_cache_budget.touch(self, block_hash)
return entry
def _pending_write_buffer_get(self, block_hash: bytes) -> dict | None:
"""Get entry from pending-write buffer. Returns None on miss."""
with self._pending_write_hashes_lock:
return self._pending_write_buffers.get(block_hash)
def _hot_cache_remove(
self, block_hash: bytes, *, update_budget: bool = True
) -> dict | None:
"""Remove entry from hot cache if present."""
with self._hot_cache_lock:
old = self._hot_cache.pop(block_hash, None)
if old:
self._hot_cache_total_bytes -= self._hot_cache_entry_size(old)
if old is not None and update_budget and self._hot_cache_budget is not None:
self._hot_cache_budget.forget(self, block_hash)
return old
def _promote_to_hot_cache(
self,
block_hash: bytes,
arrays: dict[str, Any],
file_metadata: Any,
metadata: PagedSSDBlockMetadata,
) -> None:
"""Promote a block loaded from SSD into the hot cache."""
try:
promoted_raw = {}
for name, arr in arrays.items():
promoted_raw[name] = _extract_tensor_bytes(arr)
entry = {
"tensors_raw": promoted_raw,
"file_metadata": (
file_metadata if isinstance(file_metadata, dict) else {}
),
"num_layers": metadata.num_layers,
"layer_cache_types": metadata.layer_cache_types,
"block_metadata": metadata,
"dirty": False,
}
self._hot_cache_put(block_hash, entry)
self._stats["hot_cache_promotions"] += 1
except Exception:
pass # Promotion failure is non-critical
def _init_directories(self) -> None:
"""Create cache directory structure."""
self._cache_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories for first hex character
for char in self.SUBDIR_CHARS:
subdir = self._cache_dir / char
subdir.mkdir(exist_ok=True)
def _get_file_path(self, block_hash: bytes) -> Path:
"""
Get file path for a block hash.
Uses first character of hex hash as subdirectory.
Args:
block_hash: Block content hash.
Returns:
Path to the safetensors file.
"""
hash_hex = block_hash.hex()
subdir = hash_hex[0] # First character
filename = f"{hash_hex}.safetensors"
return self._cache_dir / subdir / filename
def _tracked_ssd_size(self) -> int:
"""Return all SSD cache bytes tracked for this shared cache directory."""
return self._index.total_size + self._incompatible_index.total_size
def _tracked_ssd_count(self) -> int:
"""Return compatible plus incompatible tracked SSD cache file count."""
return self._index.count + self._incompatible_index.count
def _scan_existing_files(self) -> None:
"""Scan cache directory for existing files and build the compatible index.
Only blocks compatible with the currently loaded model/layout are
indexed. Incompatible blocks are left on disk so a shared SSD cache
directory can safely serve multiple loaded models without one model's
startup scan deleting another model's cache.
"""
logger.info(f"Scanning SSD cache directory: {self._cache_dir}")
scanned = 0
indexed = 0
skipped_incompatible = 0
skipped_incompatible_bytes = 0
errors = 0
for subdir in self.SUBDIR_CHARS:
subdir_path = self._cache_dir / subdir
if not subdir_path.exists():
continue
for file_path in subdir_path.glob("*.safetensors"):
scanned += 1
try:
metadata = self._read_file_metadata(file_path)
if metadata is None:
continue
if not self._is_compatible_block(metadata):
skipped_incompatible += 1
skipped_incompatible_bytes += metadata.file_size
self._incompatible_index.add(metadata)
continue
self._index.add(metadata)
indexed += 1
except Exception as e:
logger.warning(f"Failed to read {file_path}: {e}")
errors += 1
self._index.sort_lru_by_last_access()
self._incompatible_index.sort_lru_by_last_access()
log_msg = (
f"SSD cache scan complete: scanned={scanned}, indexed={indexed}, "
f"errors={errors}, total_size={format_bytes(self._index.total_size)}"
)
if skipped_incompatible > 0:
log_msg += (
f", skipped_incompatible={skipped_incompatible} blocks "
f"({format_bytes(skipped_incompatible_bytes)})"
)
logger.info(log_msg)
# Startup can find a cache directory that already exceeds the shared
# SSD budget. Converge immediately before serving requests.
tracked_size = self._tracked_ssd_size()
if tracked_size > 0 and tracked_size > self._get_effective_max_size():
self._enforce_size_limit_for_new_block(0, unbounded=True)
def _is_compatible_block(self, metadata: PagedSSDBlockMetadata) -> bool:
"""Return True when a block can be indexed for this manager."""
if self._expected_model_name and metadata.model_name:
if metadata.model_name != self._expected_model_name:
return False
if self._expected_num_layers > 0 and metadata.num_layers > 0:
if metadata.num_layers != self._expected_num_layers:
return False
if self._expected_block_size > 0:
if metadata.block_size <= 0:
return False
if metadata.block_size != self._expected_block_size:
return False
if self._expected_layer_cache_types is not None:
if _canonicalize_layer_cache_types(
metadata.layer_cache_types
) != _canonicalize_layer_cache_types(self._expected_layer_cache_types):
return False
if (
self._expected_layer_cache_types is not None
and not self._is_compatible_cache_signature(metadata)
):
return False
return True
def _is_compatible_cache_signature(self, metadata: PagedSSDBlockMetadata) -> bool:
"""Return True when a saved cache_signature matches enabled checks."""
if not metadata.cache_signature:
return self._signature_bits_match("")
try:
payload = json.loads(metadata.cache_signature)
except (TypeError, ValueError):
expected_signature = (
self._expected_cache_signature()
if self._expected_layer_cache_types is not None
else ""
)
return (
not expected_signature or metadata.cache_signature == expected_signature
)
if self._expected_model_name:
if payload.get("model_name", "") != self._expected_model_name:
return False
if self._expected_num_layers > 0:
try:
num_layers = int(payload.get("num_layers", 0) or 0)
except (TypeError, ValueError):
num_layers = 0
if num_layers > 0 and num_layers != self._expected_num_layers:
return False
if self._expected_block_size > 0:
try:
block_size = int(payload.get("block_size", 0) or 0)
except (TypeError, ValueError):
block_size = 0
if block_size > 0 and block_size != self._expected_block_size:
return False
if self._expected_layer_cache_types is not None:
layer_cache_types = payload.get("layer_cache_types")
if not isinstance(layer_cache_types, (list, tuple)):
return False
if _canonicalize_layer_cache_types(
layer_cache_types
) != _canonicalize_layer_cache_types(self._expected_layer_cache_types):
return False
if not self._signature_bits_match(metadata.cache_signature):
return False
return True
def _signature_bits_match(self, cache_signature: str) -> bool:
"""True when a block's recorded TurboQuant depth satisfies expectations.
With no expected depth every block passes. With one, the block must
PROVE a matching depth: the packed state width is
``ceil(head_dim * bits / 32)``, so a block written at another depth —
or one with no recorded depth (pre-depth-stamping saves) — has an
incompatible or unverifiable width, and restoring it poisons batch
concatenation (#2045).
"""
if self._expected_turboquant_kv_bits is None:
return True
return (
_signature_turboquant_bits(cache_signature)
== self._expected_turboquant_kv_bits
)
def _expected_cache_signature(self) -> str:
if (
not self._expected_model_name
and self._expected_num_layers <= 0
and self._expected_block_size <= 0
and self._expected_layer_cache_types is None
):
return ""
return _cache_compat_signature(
model_name=self._expected_model_name,
num_layers=self._expected_num_layers,
block_size=self._expected_block_size,
layer_cache_types=self._expected_layer_cache_types,
turboquant_kv_bits=self._expected_turboquant_kv_bits,
)
def _read_file_metadata(self, file_path: Path) -> PagedSSDBlockMetadata | None:
"""
Read metadata from an existing cache file.
Args:
file_path: Path to safetensors file.
Returns:
PagedSSDBlockMetadata if valid, None otherwise.
"""
if not HAS_MLX:
return None
try:
# Load just the metadata without loading tensors
_, metadata = mx.load(str(file_path), return_metadata=True)
block_hash_hex = metadata.get("block_hash", "")
if not block_hash_hex:
return None
# Reject pre-fix blocks. RotatingKVCache layers in those files
# may have been zero-padded to max_size, which the new merge
# contract would treat as real attention keys. See #934 / #903
# and the _CACHE_FORMAT_VERSION docstring for context.
#
# V3 polyfills V2 blocks at read time so already-stored caches
# stay valid after the N-tuple state refactor. Versions outside
# _READABLE_CACHE_FORMAT_VERSIONS are still rejected.
cache_version = metadata.get("omlx_cache_format_version")
if cache_version not in _READABLE_CACHE_FORMAT_VERSIONS:
logger.debug(
"Skipping cache block with unsupported format version "
"%r (readable %r): %s",
cache_version,
sorted(_READABLE_CACHE_FORMAT_VERSIONS),
file_path,
)
return None
file_stat = file_path.stat()
# Parse cache type information if present
layer_cache_types = None
layer_meta_states = None
if "layer_cache_types" in metadata and metadata["layer_cache_types"]:
try:
layer_cache_types = json.loads(metadata["layer_cache_types"])
except (json.JSONDecodeError, TypeError):
pass
if "layer_meta_states" in metadata and metadata["layer_meta_states"]:
try:
raw_meta_states = json.loads(metadata["layer_meta_states"])
layer_meta_states = [tuple(m) if m else () for m in raw_meta_states]
except (json.JSONDecodeError, TypeError):
pass
return PagedSSDBlockMetadata(
block_hash=bytes.fromhex(block_hash_hex),
file_path=file_path,
file_size=file_stat.st_size,
token_count=int(metadata.get("token_count", 0)),
created_at=file_stat.st_ctime,
last_access=file_stat.st_mtime,
num_layers=int(metadata.get("num_layers", 0)),
model_name=metadata.get("model_name", ""),
block_size=int(metadata.get("block_size", 0)),
cache_signature=metadata.get("cache_signature", ""),
layer_cache_types=layer_cache_types,
layer_meta_states=layer_meta_states,
)
except Exception as e:
logger.debug(f"Failed to read metadata from {file_path}: {e}")
return None
def _write_block_file(
self,
block_hash: bytes,
tensors_raw: dict[str, Any],
metadata: dict[str, str],
file_path: Path,
*,
source: str,
) -> bool:
"""Write one serialized block to disk from raw tensor bytes."""
temp_path = None
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = file_path.with_name(file_path.stem + "_tmp.safetensors")
actual_size = _write_safetensors_no_mx(
str(temp_path), tensors_raw, metadata
)
os.rename(str(temp_path), str(file_path))
# The block is now durable on disk; bump the persist counter
# before any cleanup so ``saves_persisted`` reflects rename
# success even if the post-rename eviction check below unlinks it.
self._stats["saves_persisted"] += 1
self._index.update_file_size(block_hash, actual_size)
# Check if block was evicted while write was pending.
if not self._index.contains(block_hash):
logger.debug(
"Block %s evicted during %s write, cleaning up file",
block_hash.hex()[:16],
source,
)
with contextlib.suppress(Exception):
file_path.unlink()
return True
except Exception as e:
if isinstance(e, OSError) and e.errno in (
errno.ENOSPC,
errno.EDQUOT,
):
# Background writes may fail after save_block already returned
# True, while inline fallbacks can still report False to the
# caller. In both cases, surface disk pressure at ERROR level
# and force the next save to recompute available space.
logger.error(
"SSD cache disk full, cannot write block %s via %s: %s "
"(subsequent saves will recompute disk pressure)",
block_hash.hex()[:16],
source,
e,
)
# Invalidate the 30s disk-usage snapshot so the next
# save sees the true (now-critical) free space and evicts
# aggressively rather than trusting a stale inflated limit.
# In-flight saves that already passed
# _enforce_size_limit_for_new_block are still queued and may
# ENOSPC again; invalidation only protects the next round of
# save_block calls.
with self._lock:
self._disk_usage_cache = None
else:
logger.error(
"SSD cache %s write failed for %s: %s",
source,
block_hash.hex()[:16],
e,
)
self._stats["errors"] += 1
self._index.remove(block_hash)
for p in (temp_path, file_path):
with contextlib.suppress(Exception):
if p is not None and isinstance(p, Path) and p.exists():
p.unlink()
return False
def _clear_pending_write(
self, block_hash: bytes, *, remove_hot_cache: bool = False
) -> None:
"""Clear pending-write bookkeeping after a queued or inline write."""
with self._pending_write_hashes_lock:
self._pending_write_hashes.discard(block_hash)
self._pending_write_buffers.pop(block_hash, None)
if remove_hot_cache:
self._hot_cache_remove(block_hash)
def _writer_loop(self) -> None:
"""Background writer that drains the write queue.
Runs in a dedicated daemon thread. Writes full safetensors files
using pure Python I/O (no mx/Metal API calls), then atomically
renames temp files to their final paths.
This is safe because save_block() extracts tensor data as raw bytes
on the inference thread (Metal-safe), and this thread only performs
standard file I/O operations.
"""
while True:
item = None
try:
item = self._write_queue.get(timeout=1.0)
except queue.Empty:
# Exit if shutdown was requested and queue is empty
if self._writer_shutdown.is_set():
break
continue
if item is None: # Sentinel for shutdown
break
block_hash, tensors_raw, metadata, file_path = item
try:
self._write_block_file(
block_hash, tensors_raw, metadata, file_path, source="background"
)
self._clear_pending_write(
block_hash, remove_hot_cache=not self._hot_cache_enabled
)
finally:
# Avoid pinning the last raw tensor-byte batch while the
# writer thread blocks waiting for more work.
item = None
block_hash = tensors_raw = metadata = file_path = None
def save_block(
self,
block_hash: bytes,
cache_data: list[Any],
token_count: int,
model_name: str = "",
layer_cache_types: list[str] | None = None,
layer_meta_states: list[tuple] | None = None,
hot_cache_write_back: bool = True,
) -> bool:
"""
Save a KV cache block to SSD storage (non-blocking).
Data is enqueued for background writing. The block is immediately
available for reads via the in-memory pending-writes buffer.
Args:
block_hash: Content hash for the block.
cache_data: List of per-layer data. Each element is either:
- (keys, values) tuple for standard caches (KVCache, etc.)
- ('__cache_list__', sub_tensors) marker tuple for CacheList layers,
where sub_tensors is List[Tuple[keys, values]] per sub-cache.
token_count: Number of tokens in the block.
model_name: Model name for cache isolation between different models.
layer_cache_types: Optional list of cache type names per layer
(e.g., ["KVCache", "ArraysCache", "KVCache", "CacheList"]).
layer_meta_states: Optional list of meta_state tuples per layer
for reconstruction (e.g., [(offset,), (keep, max_size, offset, _idx)]).
hot_cache_write_back: When False in SSD-backed hot-cache mode, enqueue
through the SSD writer path instead of retaining a hot-cache copy.
Returns:
True if enqueued successfully, False otherwise.
"""
if not HAS_MLX:
logger.error("MLX not available, cannot save block")
return False
layer_cache_types = _storage_layer_cache_types(layer_cache_types)
# First save call after a model load is the canonical source for
# the live layer-cache signature (post-TurboQuant / post-MTP). If
# the manager wasn't told the signature at construction, adopt it
# now and sweep any index entries left over from a prior config.
if self.adopt_layer_signature_if_unset(layer_cache_types):
try:
self.invalidate_stale_layer_signature()
except Exception as e:
logger.warning("Stale-signature sweep failed: %s", e)
# Check if already exists in index (thread-safe)
if self._index.contains(block_hash):
self._index.touch(block_hash)
self._stats["hits"] += 1
return True
# Also check hot cache / pending writes buffer
hot_entry = None
with self._hot_cache_lock:
if block_hash in self._hot_cache:
hot_entry = self._hot_cache[block_hash]
if hot_entry is not None:
if hot_cache_write_back or self._hot_cache_only:
self._stats["hits"] += 1
return True
if not hot_entry.get("dirty", True):
self._stats["hits"] += 1
return True
# Pressure write-through for an already-dirty hot-cache entry:
# use the same pending-buffer / SSD-writer path as hot-cache
# eviction, then drop the long-lived hot-cache reference.
if self._enqueue_ssd_write(block_hash, hot_entry):
self._hot_cache_remove(block_hash)
self._stats["hits"] += 1
return True
return False
file_path = self._get_file_path(block_hash)
try:
# Prepare arrays for safetensors. Three layer_data shapes are
# accepted:
# - ``('__nstate__', class_name, [elem0, elem1, ...])`` — V3
# N-tuple state from a handler-driven serialize_state path.
# - ``('__cache_list__', sub_tensors)`` — composite layer; each
# sub_tensor may itself be a 2-tuple ``(keys, values)`` (V2
# legacy from prefix_cache) or an ``__nstate__`` marker.
# - ``('__turboquant__'/'__turboquant_v2__', ...)`` — bespoke
# TurboQuant payload, unchanged.
# - ``(keys, values)`` 2-tuple — V2 legacy. Promoted to V3 by
# storing as a length-2 ``__nstate__`` so the on-disk shape
# is uniform regardless of whether the producer (prefix_cache,
# etc.) has been migrated to emit ``__nstate__`` markers yet.
arrays = {}
cache_list_meta = (
{}
) # Per-layer sidecar metadata (sub_count, state_count, etc.)
def _store_nstate_elements(prefix: str, elements):
"""Write N elements as ``{prefix}_state_{k}`` keys with a
``{prefix}_state_count`` count marker. Zero-dim shapes are
preserved via ``{prefix}_state_{k}_zero_dim``. Composite
elements (a bare tuple/list or a nested ``__nstate__``
marker) recurse under a ``{elem_key}`` sub-prefix and record
a ``{elem_key}_nested`` marker; the flat ``{elem_key}``
tensor is deliberately omitted so an older reader hits its
``Missing {elem_key} in arrays`` path and skips the block."""
cache_list_meta[f"{prefix}_state_count"] = str(len(elements))
for k, elem in enumerate(elements):
elem_key = f"{prefix}_state_{k}"
if elem is None:
# None placeholder — store an empty marker tensor
# and a sentinel zero_dim entry so the loader can
# restore None instead of materializing zeros.
arrays[elem_key] = mx.zeros((1,))
cache_list_meta[f"{elem_key}_none"] = "1"
elif _has_zero_dim(elem):
arrays[elem_key] = mx.zeros((1,))
cache_list_meta[f"{elem_key}_zero_dim"] = _encode_shape(
elem.shape
)
elif (
isinstance(elem, tuple)
and len(elem) >= 2
and isinstance(elem[0], str)
and elem[0] == "__nstate__"
):
# Nested ``('__nstate__', class_name, [sub...])``
# marker — recurse, no flat tensor written.
cache_list_meta[f"{elem_key}_nested"] = "nstate"
sub_class = elem[1] if len(elem) >= 2 else None
sub_elements = elem[2] if len(elem) >= 3 else []
if sub_class:
cache_list_meta[f"{elem_key}_state_class_name"] = sub_class
_store_nstate_elements(elem_key, sub_elements)
elif isinstance(elem, (tuple, list)):
# Bare tuple/list of sub-elements — recurse, no flat
# tensor written.
cache_list_meta[f"{elem_key}_nested"] = "tuple"
_store_nstate_elements(elem_key, list(elem))
else:
if not isinstance(elem, mx.array):
raise TypeError(
f"unsupported non-array nstate element "
f"{elem_key}: {type(elem).__name__}"
)
arrays[elem_key] = elem
for i, layer_data in enumerate(cache_data):
if (
isinstance(layer_data, tuple)
and len(layer_data) >= 2
and isinstance(layer_data[0], str)
and layer_data[0] == "__nstate__"
):
# ('__nstate__', class_name, [elements]) — V3 native
class_name = layer_data[1] if len(layer_data) >= 2 else None
elements = layer_data[2] if len(layer_data) >= 3 else []
if class_name:
cache_list_meta[f"layer_{i}_state_class_name"] = class_name
_store_nstate_elements(f"layer_{i}", elements)
elif (
isinstance(layer_data, tuple)
and len(layer_data) == 2
and isinstance(layer_data[0], str)
and layer_data[0] == "__cache_list__"
):
# CacheList: sub-indexed tensors. Each sub_tensor may be
# a 2-tuple (legacy) or an ``__nstate__`` marker.
sub_tensors = layer_data[1]
cache_list_meta[f"layer_{i}_sub_count"] = str(len(sub_tensors))
for j, sub_tensor in enumerate(sub_tensors):
sub_prefix = f"layer_{i}_sub_{j}"
if (
isinstance(sub_tensor, tuple)
and len(sub_tensor) >= 2
and isinstance(sub_tensor[0], str)
and sub_tensor[0] == "__nstate__"
):
sub_class_name = (
sub_tensor[1] if len(sub_tensor) >= 2 else None
)
sub_elements = sub_tensor[2] if len(sub_tensor) >= 3 else []
if sub_class_name:
cache_list_meta[f"{sub_prefix}_state_class_name"] = (
sub_class_name
)
_store_nstate_elements(sub_prefix, sub_elements)
elif (
isinstance(sub_tensor, (list, tuple))
and len(sub_tensor) >= 2
):
# V2 legacy: treat as N-tuple with no class name.
_store_nstate_elements(sub_prefix, list(sub_tensor))
else:
logger.error(
f"Unsupported sub_tensor format at layer {i} "
f"sub {j}: {type(sub_tensor).__name__}"
)
return False
elif (
isinstance(layer_data, tuple)
and len(layer_data) == 2
and isinstance(layer_data[0], str)
and layer_data[0] in ("__turboquant__", "__turboquant_v2__")
):
# TurboQuant v2: NamedTuple states (ks, vs)
ks, vs = layer_data[1]
# Flatten NamedTuple fields into individual tensors
tq_tensor_idx = 0
for prefix, state in [("k", ks), ("v", vs)]:
for field_name in state._fields:
val = getattr(state, field_name)
if isinstance(val, mx.array):
arrays[f"layer_{i}_tq_{prefix}_{field_name}"] = val
tq_tensor_idx += 1
cache_list_meta[f"layer_{i}_turboquant_v2"] = "1"
cache_list_meta[f"layer_{i}_tq_key_type"] = type(ks).__name__
cache_list_meta[f"layer_{i}_tq_value_type"] = type(vs).__name__
cache_list_meta[f"layer_{i}_tq_key_fields"] = ",".join(ks._fields)
cache_list_meta[f"layer_{i}_tq_value_fields"] = ",".join(vs._fields)
else:
# V2 legacy: 2-tuple (keys, values). Upgrade to V3
# __nstate__ on disk so all readers see a uniform shape.
if not (
isinstance(layer_data, (list, tuple)) and len(layer_data) >= 2
):
logger.error(
f"Unsupported layer_data format at layer {i}: "
f"{type(layer_data).__name__}"
)
return False
_store_nstate_elements(f"layer_{i}", list(layer_data))
block_size = self._expected_block_size or token_count
# Stamp the depth the block's own TurboQuant layers were packed
# at (observation), falling back to the manager's expectation
# only when the block carries no meta_state. A signature must
# never vouch for a width the payload does not have.
block_bits = _block_turboquant_bits(layer_cache_types, layer_meta_states)
cache_signature = _cache_compat_signature(
model_name=model_name,
num_layers=len(cache_data),
block_size=block_size,
layer_cache_types=layer_cache_types,
turboquant_kv_bits=(
block_bits
if block_bits is not None
else self._expected_turboquant_kv_bits
),
)
# Prepare metadata
metadata = {
"omlx_cache_format_version": _CACHE_FORMAT_VERSION,
"block_hash": block_hash.hex(),
"token_count": str(token_count),
"num_layers": str(len(cache_data)),
"model_name": model_name,
"block_size": str(block_size),
"cache_signature": cache_signature,
"created_at": str(time.time()),
}
# Add cache type information if provided
if layer_cache_types:
metadata["layer_cache_types"] = json.dumps(layer_cache_types)
if layer_meta_states:
clamped_meta_states = _clamp_rotating_meta_states(
cache_data, layer_cache_types, layer_meta_states
)
metadata["layer_meta_states"] = json.dumps(
[list(m) if m else [] for m in clamped_meta_states]
)
# Merge CacheList sub_count metadata
metadata.update(cache_list_meta)
# Last-mile materialization happens in _extract_tensor_bytes.
# scheduler._cleanup_finished still pre-dispatches real KV arrays,
# but store_cache creates additional lazy slices, clones, and
# placeholders here after that collection step. Evaluate those
# derived arrays before memoryview() so the buffer protocol never
# becomes the first MLX eval site on the store-cache worker thread.
# Race history: #978/#1040/#1106/#1437/#1558.
tensors_raw = {}
for name, arr in arrays.items():
tensors_raw[name] = _extract_tensor_bytes(arr)
# Estimate file size: raw tensor bytes + safetensors header.
# The header is JSON-encoded per tensor (name + dtype + shape +
# data_offsets, typically ~85 bytes) plus an 8-byte length prefix
# and the user metadata block. Compute the metadata-JSON length
# exactly (large `layer_meta_states` JSON on deep-layer models
# can exceed a fixed 1 KiB constant) and keep 128 B/tensor as an
# upper bound on the per-tensor header. The 256 B margin covers
# the JSON separators / `__metadata__` key envelope safetensors
# adds at write time.
try:
metadata_json_len = len(json.dumps(metadata).encode("utf-8"))
except (TypeError, ValueError):
metadata_json_len = 1024
header_overhead = metadata_json_len + 256 + 128 * len(tensors_raw)
estimated_size = (
sum(len(raw) for raw, _, _ in tensors_raw.values()) + header_overhead
)
now = time.time()
block_metadata = PagedSSDBlockMetadata(
block_hash=block_hash,
file_path=file_path,
file_size=estimated_size,
token_count=token_count,
created_at=now,
last_access=now,
num_layers=len(cache_data),
model_name=model_name,
block_size=block_size,
cache_signature=cache_signature,
layer_cache_types=layer_cache_types,
layer_meta_states=layer_meta_states,
)
# Store in hot cache (or temporary buffer) for immediate read-back.
# Uses raw bytes (not mx.array objects) so Metal GPU memory can be
# released as soon as the inference thread is done with the arrays.
# NOTE: _promote_to_hot_cache() stores mx.array objects directly
# because those are freshly loaded from SSD (not active inference),
# so they don't tie up Metal allocations from the inference pipeline.
# Storing live inference arrays here would accumulate GPU memory
# under a large hot cache and cause kernel panics (IOGPUMemory underflow).
cache_entry = {
"tensors_raw": tensors_raw,
"file_metadata": metadata,
"num_layers": len(cache_data),
"layer_cache_types": layer_cache_types,
"block_metadata": block_metadata,
"dirty": True,
}
if self._hot_cache_enabled and (
hot_cache_write_back or self._hot_cache_only
):
# Write-back mode: store only in hot cache, no SSD index entry.
# SSD index entry is created later when block is evicted or
# flushed to SSD (in _enqueue_ssd_write).
self._hot_cache_put(block_hash, cache_entry)
self._stats["saves"] += 1
return True
if self._hot_cache_only:
# Hot cache disabled but hot_cache_only set: block is not retained.
return False
if self._hot_cache_enabled and not hot_cache_write_back:
# Pressure write-through: keep the dirty-block durability path
# but avoid retaining this block as a hot-cache entry.
ok = self._enqueue_ssd_write(block_hash, cache_entry)
if ok:
self._stats["saves"] += 1
logger.debug(
f"Enqueued block for SSD write-through: "
f"{block_hash.hex()[:16]}..., "
f"size={format_bytes(estimated_size)}"
)
return ok
# Evict LRU blocks to make room for the new block. Done here
# (post-tensor-build) so the actual block size is known and the
# cache doesn't oscillate around the configured limit.
self._enforce_size_limit_for_new_block(estimated_size)
# SSD path: add to index for SSD file tracking
self._incompatible_index.remove(block_hash)
self._index.add(block_metadata)
# Hot cache disabled: use temporary buffer + immediate SSD write
with self._hot_cache_lock:
self._hot_cache[block_hash] = cache_entry
# Track pending write
with self._pending_write_hashes_lock:
self._pending_write_hashes.add(block_hash)
# Enqueue full file write for background thread. Wait on Full so
# transient bursts (faster than the writer can drain) don't
# immediately punch holes in the cache chain.
try:
self._write_queue.put(
(block_hash, tensors_raw, metadata, file_path),
timeout=_PENDING_WRITE_PUT_TIMEOUT_SECONDS,
)
except queue.Full:
self._stats["ssd_inline_write_fallbacks"] += 1
logger.warning(
f"SSD cache write queue saturated (cap={self._max_pending_writes}); "
f"writing {block_hash.hex()[:16]} inline"
)
ok = self._write_block_file(
block_hash,
tensors_raw,
metadata,
file_path,
source="inline-fallback",
)
self._clear_pending_write(block_hash, remove_hot_cache=True)
if not ok:
return False
self._stats["saves"] += 1
return True
self._stats["saves"] += 1
logger.debug(
f"Enqueued block for SSD cache write: {block_hash.hex()[:16]}..., "
f"size={format_bytes(estimated_size)}"
)
return True
except Exception as e:
logger.error(f"Failed to prepare block for SSD cache: {e}")
self._stats["errors"] += 1
return False
def _reconstruct_cache_data(
self,
arrays: dict[str, Any],
file_metadata: dict[str, str],
num_layers: int,
layer_cache_types: list[str] | None = None,
) -> list[Any] | None:
"""Reconstruct cache_data list from flattened arrays and metadata.
Shared helper for load_block(), load_block_with_metadata(), and
pending-writes read path to avoid code duplication.
Returns layer_data as one of:
- ``('__nstate__', class_name, [elem0, elem1, ...])`` — V3 N-tuple.
- ``('__cache_list__', sub_tensors)`` where each sub_tensor is an
``__nstate__`` marker — composite layer.
- ``('__turboquant_v2__', (ks, vs))`` — TurboQuant payload (unchanged).
V2 blocks (`layer_{i}_keys` / `layer_{i}_values` keys, no
``state_count`` metadata) are read via a polyfill that converts
them to ``__nstate__`` markers with two elements, so downstream
code paths see a uniform shape.
Args:
arrays: Flattened tensor dict.
file_metadata: Safetensors metadata dict (string values).
num_layers: Number of model layers.
layer_cache_types: Per-layer cache type names.
Returns:
Reconstructed cache_data list, or None on error.
"""
cache_data: list[Any] = []
# When the on-disk state has exactly two elements (which covers all
# legacy 2-tuple caches: KVCache, RotatingKVCache, ConcatenateKVCache,
# ChunkedKVCache, QuantizedKVCache when stored as keys/values), the
# reconstructed layer is unwrapped to a plain ``(keys, values)``
# 2-tuple so existing callers (prefix_cache, scheduler, tests) see
# no change. Real N-tuple caches (PoolingCache, BatchKVCache, ...)
# surface as ``('__nstate__', class_name, elements)`` markers that
# downstream code must dispatch on.
def _maybe_unwrap_legacy(marker: tuple) -> Any:
_, _, elements = marker
# Only the legacy plain ``(keys, values)`` shape unwraps. If
# either element is itself composite (a recursed sub-state), the
# ``__nstate__`` wrapper must survive so the shape matches save.
if len(elements) == 2 and not any(
isinstance(e, (tuple, list)) for e in elements
):
return (elements[0], elements[1])
return marker
def _load_nstate(prefix: str, fallback_class: str | None) -> tuple | None:
"""Read either V3 ``state_count`` keys or V2 ``keys``/``values``
polyfill at ``prefix``. Returns ``('__nstate__', class_name, elements)``
on success or None on missing tensors."""
count_key = f"{prefix}_state_count"
class_name = None
if file_metadata:
class_name = file_metadata.get(f"{prefix}_state_class_name")
if class_name is None:
class_name = fallback_class
elements: list[Any] = []
if file_metadata and count_key in file_metadata:
# V3 path
try:
count = int(file_metadata[count_key])
except (ValueError, TypeError):
return None
for k in range(count):
elem_key = f"{prefix}_state_{k}"
none_marker = f"{elem_key}_none"
zd_marker = f"{elem_key}_zero_dim"
nested_marker = f"{elem_key}_nested"
if file_metadata and none_marker in file_metadata:
elements.append(None)
continue
if file_metadata and nested_marker in file_metadata:
# Composite element — recurse, then restore the same
# shape it had on save (bare tuple vs __nstate__).
sub = _load_nstate(elem_key, fallback_class=None)
if sub is None:
return None
if file_metadata[nested_marker] == "tuple":
elements.append(tuple(sub[2]))
elif file_metadata[nested_marker] == "nstate":
# Explicit marker on write — preserve the full
# ('__nstate__', class_name, elements) as-is;
# never unwrap (would drop the marker/class_name).
elements.append(sub)
else:
# Corrupt/unknown nested marker — fail closed.
return None
continue
if elem_key not in arrays:
logger.error(f"Missing {elem_key} in arrays")
return None
if file_metadata and zd_marker in file_metadata:
elements.append(
mx.zeros(_decode_shape(file_metadata[zd_marker]))
)
else:
elements.append(arrays[elem_key])
else:
# V2 polyfill: legacy ``{prefix}_keys`` / ``{prefix}_values``.
keys_key = f"{prefix}_keys"
values_key = f"{prefix}_values"
if keys_key not in arrays or values_key not in arrays:
return None
k_zd = f"{prefix}_keys_zero_dim"
v_zd = f"{prefix}_values_zero_dim"
if file_metadata and k_zd in file_metadata:
elements.append(mx.zeros(_decode_shape(file_metadata[k_zd])))
else:
elements.append(arrays[keys_key])
if file_metadata and v_zd in file_metadata:
elements.append(mx.zeros(_decode_shape(file_metadata[v_zd])))
else:
elements.append(arrays[values_key])
return ("__nstate__", class_name, elements)
for i in range(num_layers):
cache_type = (
layer_cache_types[i]
if layer_cache_types and i < len(layer_cache_types)
else None
)
if cache_type == "CacheList":
sub_count_key = f"layer_{i}_sub_count"
sub_count = 0
if file_metadata and sub_count_key in file_metadata:
try:
sub_count = int(file_metadata[sub_count_key])
except (ValueError, TypeError):
pass
if sub_count > 0:
sub_tensors: list[Any] = []
for j in range(sub_count):
sub_marker = _load_nstate(
f"layer_{i}_sub_{j}", fallback_class=None
)
if sub_marker is None:
logger.error(
f"Missing sub-cache {j} for CacheList layer {i}"
)
return None
# Length-2 sub-states unwrap to (keys, values); longer
# N-tuples surface as ``__nstate__`` markers downstream.
sub_tensors.append(_maybe_unwrap_legacy(sub_marker))
# Preserve the legacy list shape — callers (prefix_cache,
# tests) expect ``cache_data[i]`` to be a list of
# sub-cache states for CacheList layers, not a wrapper
# marker.
cache_data.append(sub_tensors)
else:
layer_marker = _load_nstate(f"layer_{i}", fallback_class=cache_type)
if layer_marker is None:
logger.error(f"Missing N-tuple state for layer {i}")
return None
cache_data.append(_maybe_unwrap_legacy(layer_marker))
elif file_metadata and f"layer_{i}_turboquant_v2" in file_metadata:
# TurboQuant v2: reconstruct NamedTuple states from flattened tensors
from ..turboquant_kv import (
TurboQuantMSEState,
TurboQuantPolarProdState,
TurboQuantPolarState,
TurboQuantProdState,
TurboQuantSplitState,
)
key_type = file_metadata.get(f"layer_{i}_tq_key_type", "")
value_type = file_metadata.get(f"layer_{i}_tq_value_type", "")
key_fields = file_metadata.get(f"layer_{i}_tq_key_fields", "").split(
","
)
value_fields = file_metadata.get(
f"layer_{i}_tq_value_fields", ""
).split(",")
_type_map = {
"TurboQuantMSEState": TurboQuantMSEState,
"TurboQuantProdState": TurboQuantProdState,
"TurboQuantPolarState": TurboQuantPolarState,
"TurboQuantPolarProdState": TurboQuantPolarProdState,
"TurboQuantSplitState": TurboQuantSplitState,
}
try:
k_cls = _type_map[key_type]
v_cls = _type_map[value_type]
k_tensors = [arrays[f"layer_{i}_tq_k_{f}"] for f in key_fields]
v_tensors = [arrays[f"layer_{i}_tq_v_{f}"] for f in value_fields]
ks = k_cls(*k_tensors)
vs = v_cls(*v_tensors)
cache_data.append(("__turboquant_v2__", (ks, vs)))
except (KeyError, TypeError) as e:
logger.error(f"TurboQuant v2 layer {i}: reconstruction failed: {e}")
return None
else:
# Standard cache layer (KVCache, RotatingKVCache,
# PoolingCache, ...). V3 stores all state elements as
# ``layer_{i}_state_{k}``; V2 polyfill reads the legacy
# ``layer_{i}_keys`` / ``layer_{i}_values`` 2-tuple shape.
# Length-2 markers unwrap to ``(keys, values)`` for legacy
# caller compatibility; longer N-tuples (PoolingCache etc.)
# propagate as ``__nstate__`` markers.
layer_marker = _load_nstate(f"layer_{i}", fallback_class=cache_type)
if layer_marker is None:
logger.error(f"Missing N-tuple state for layer {i}")
return None
cache_data.append(_maybe_unwrap_legacy(layer_marker))
return cache_data
@staticmethod
def _arrays_from_tensors_raw(
tensors_raw: dict[str, tuple[bytes, str, list[int]]],
) -> dict[str, mx.array]:
"""Convert raw bytes dict back to mx.array dict for _reconstruct_cache_data.
Args:
tensors_raw: Dict of {name: (raw_bytes, dtype_str, shape)}.
Returns:
Dict of {name: mx.array} with correct dtypes and shapes.
"""
arrays = {}
for name, (raw, dtype_str, shape) in tensors_raw.items():
arrays[name] = _restore_tensor_from_bytes(raw, dtype_str, shape)
return arrays
def load_block(self, block_hash: bytes) -> list[Any] | None:
"""
Load a KV cache block from SSD storage.
Checks pending writes first (in-memory, no I/O), then falls back to disk
read with a timeout to prevent inference deadlocks.
Args:
block_hash: Content hash for the block.
Returns:
List of per-layer data, or None if not found/timed out.
Each element is either:
- (keys, values) tuple for standard caches
- List[Tuple[keys, values]] for CacheList layers
"""
if not HAS_MLX:
logger.error("MLX not available, cannot load block")
return None
# Check hot cache first (in-memory, no I/O)
entry = self._hot_cache_get(block_hash)
if entry is not None:
# Entries from _promote_to_hot_cache() store mx.array objects directly
# (safe — they come from SSD loads, not active inference).
# Entries from save_block() use tensors_raw (raw bytes).
arrays = entry.get("arrays") or self._arrays_from_tensors_raw(
entry["tensors_raw"]
)
cache_data = self._reconstruct_cache_data(
arrays,
entry["file_metadata"],
entry["num_layers"],
entry["layer_cache_types"],
)
if cache_data is not None:
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
self._stats["hot_cache_hits"] += 1
logger.debug(f"Loaded block from hot cache: {block_hash.hex()[:16]}...")
return cache_data
# Check pending-write buffer (evicted from hot cache, SSD write in progress)
entry = self._pending_write_buffer_get(block_hash)
if entry is not None:
arrays = entry.get("arrays") or self._arrays_from_tensors_raw(
entry["tensors_raw"]
)
cache_data = self._reconstruct_cache_data(
arrays,
entry["file_metadata"],
entry["num_layers"],
entry["layer_cache_types"],
)
if cache_data is not None:
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
self._stats["hot_cache_hits"] += 1
logger.debug(
f"Loaded block from pending write buffer: "
f"{block_hash.hex()[:16]}..."
)
return cache_data
# Check index
metadata = self._index.get(block_hash)
if metadata is None:
self._stats["misses"] += 1
return None
file_path = metadata.file_path
if not file_path.exists():
logger.warning(f"SSD cache file missing: {file_path}")
self._index.remove(block_hash)
self._stats["misses"] += 1
return None
try:
# Load directly on the inference thread (Metal-safe).
# SSD read for a ~10MB block takes ~2ms @ 5GB/s — negligible.
# Previous executor-based approach caused deadlocks when
# mx.load() in a worker thread contested Metal GPU resources
# with the main inference thread.
try:
arrays, file_metadata = mx.load(str(file_path), return_metadata=True)
except FileNotFoundError:
# Concurrent evictor unlinked the file between the
# exists() check above and this load. Treat as a miss
# and prune the stale index entry.
self._index.remove(block_hash)
self._stats["misses"] += 1
return None
# Defensive: even if the index is stale (e.g. from a previous
# run that pre-dates the format version field), reject blocks
# without a readable version marker before they can poison
# the hot cache or downstream merge logic.
if (
file_metadata
and file_metadata.get("omlx_cache_format_version")
not in _READABLE_CACHE_FORMAT_VERSIONS
):
self._index.remove(block_hash)
self._stats["misses"] += 1
return None
# Get layer_cache_types for CacheList detection
layer_cache_types = metadata.layer_cache_types
if (
not layer_cache_types
and file_metadata
and "layer_cache_types" in file_metadata
):
try:
layer_cache_types = json.loads(file_metadata["layer_cache_types"])
except (json.JSONDecodeError, TypeError):
layer_cache_types = None
cache_data = self._reconstruct_cache_data(
arrays,
file_metadata,
metadata.num_layers,
layer_cache_types,
)
if cache_data is None:
return None
# Update access time
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
# Promote to hot cache for faster access next time
if self._hot_cache_enabled:
self._promote_to_hot_cache(block_hash, arrays, file_metadata, metadata)
logger.debug(f"Loaded block from SSD cache: {block_hash.hex()[:16]}...")
return cache_data
except Exception as e:
logger.error(f"Failed to load block from SSD cache: {e}")
self._stats["errors"] += 1
# Remove corrupted entry
self._index.remove(block_hash)
try:
file_path.unlink()
except Exception:
pass
return None
def load_block_with_metadata(
self,
block_hash: bytes,
promote_to_hot_cache: bool = True,
) -> tuple[list[Any] | None, dict[str, Any] | None]:
"""
Load a KV cache block with its metadata from SSD storage.
Checks pending writes first (zero I/O), then falls back to disk
read with a timeout to prevent inference deadlocks.
Args:
block_hash: Content hash for the block.
promote_to_hot_cache: When False, do not retain SSD-loaded data in
the hot cache after reconstructing it for the active request.
Returns:
Tuple of (cache_data, metadata_dict) where:
- cache_data: List of per-layer data, or None.
Each element is either (keys, values) or List[Tuple[keys, values]]
for CacheList layers.
- metadata_dict: Dictionary with cache type info, or None
{
"layer_cache_types": List[str], # per-layer type names
"layer_meta_states": List[Tuple], # per-layer meta states
"num_layers": int,
"token_count": int,
}
"""
if not HAS_MLX:
logger.error("MLX not available, cannot load block")
return None, None
# Check hot cache first (in-memory, no I/O)
entry = self._hot_cache_get(block_hash)
if entry is not None:
blk_meta = entry["block_metadata"]
arrays = entry.get("arrays") or self._arrays_from_tensors_raw(
entry["tensors_raw"]
)
cache_data = self._reconstruct_cache_data(
arrays,
entry["file_metadata"],
entry["num_layers"],
entry["layer_cache_types"],
)
if cache_data is None:
return None, None
metadata_dict = {
"num_layers": entry["num_layers"],
"token_count": blk_meta.token_count,
"model_name": blk_meta.model_name,
"block_size": blk_meta.block_size,
"cache_signature": blk_meta.cache_signature,
"layer_cache_types": entry["layer_cache_types"],
"layer_meta_states": blk_meta.layer_meta_states,
}
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
self._stats["hot_cache_hits"] += 1
logger.debug(
f"Loaded block with metadata from hot cache: "
f"{block_hash.hex()[:16]}..."
)
return cache_data, metadata_dict
# Check pending-write buffer (evicted from hot cache, SSD write in progress)
entry = self._pending_write_buffer_get(block_hash)
if entry is not None:
blk_meta = entry["block_metadata"]
arrays = entry.get("arrays") or self._arrays_from_tensors_raw(
entry["tensors_raw"]
)
cache_data = self._reconstruct_cache_data(
arrays,
entry["file_metadata"],
entry["num_layers"],
entry["layer_cache_types"],
)
if cache_data is None:
return None, None
metadata_dict = {
"num_layers": entry["num_layers"],
"token_count": blk_meta.token_count,
"model_name": blk_meta.model_name,
"block_size": blk_meta.block_size,
"cache_signature": blk_meta.cache_signature,
"layer_cache_types": entry["layer_cache_types"],
"layer_meta_states": blk_meta.layer_meta_states,
}
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
self._stats["hot_cache_hits"] += 1
logger.debug(
f"Loaded block with metadata from pending write buffer: "
f"{block_hash.hex()[:16]}..."
)
return cache_data, metadata_dict
# Check index
block_metadata = self._index.get(block_hash)
if block_metadata is None:
self._stats["misses"] += 1
return None, None
file_path = block_metadata.file_path
if not file_path.exists():
logger.warning(f"SSD cache file missing: {file_path}")
self._index.remove(block_hash)
self._stats["misses"] += 1
return None, None
try:
# Load directly on the inference thread (Metal-safe).
# See load_block() for rationale on removing the executor.
arrays, file_metadata = mx.load(str(file_path), return_metadata=True)
# Defensive version check, mirrors load_block().
if (
file_metadata
and file_metadata.get("omlx_cache_format_version")
not in _READABLE_CACHE_FORMAT_VERSIONS
):
self._index.remove(block_hash)
self._stats["misses"] += 1
return None, None
# Parse layer_cache_types early for CacheList detection
layer_cache_types = block_metadata.layer_cache_types
if (
not layer_cache_types
and file_metadata
and "layer_cache_types" in file_metadata
):
try:
layer_cache_types = json.loads(file_metadata["layer_cache_types"])
except (json.JSONDecodeError, TypeError):
layer_cache_types = None
cache_data = self._reconstruct_cache_data(
arrays,
file_metadata,
block_metadata.num_layers,
layer_cache_types,
)
if cache_data is None:
return None, None
# Build metadata dict for reconstruction
metadata_dict = {
"num_layers": block_metadata.num_layers,
"token_count": block_metadata.token_count,
"model_name": block_metadata.model_name,
"block_size": block_metadata.block_size,
"cache_signature": block_metadata.cache_signature,
"layer_cache_types": layer_cache_types,
"layer_meta_states": block_metadata.layer_meta_states,
}
if not metadata_dict["layer_meta_states"] and file_metadata:
if "layer_meta_states" in file_metadata:
try:
raw = json.loads(file_metadata["layer_meta_states"])
metadata_dict["layer_meta_states"] = [
tuple(m) if m else () for m in raw
]
except (json.JSONDecodeError, TypeError):
pass
# Update access time
self._index.touch(block_hash)
self._stats["loads"] += 1
self._stats["hits"] += 1
# Promote to hot cache for faster access next time
if self._hot_cache_enabled and promote_to_hot_cache:
self._promote_to_hot_cache(
block_hash, arrays, file_metadata, block_metadata
)
logger.debug(
f"Loaded block with metadata from SSD cache: {block_hash.hex()[:16]}..."
)
return cache_data, metadata_dict
except Exception as e:
logger.error(f"Failed to load block from SSD cache: {e}")
self._stats["errors"] += 1
# Remove corrupted entry
self._index.remove(block_hash)
try:
file_path.unlink()
except Exception:
pass
return None, None
def get_block_metadata(self, block_hash: bytes) -> PagedSSDBlockMetadata | None:
"""
Get metadata for a block without loading the data.
Args:
block_hash: Content hash for the block.
Returns:
PagedSSDBlockMetadata if found, None otherwise.
"""
return self._index.get(block_hash)
def has_block(self, block_hash: bytes) -> bool:
"""
Check if a block exists in cache (hot cache, pending writes, or SSD storage).
Args:
block_hash: Content hash for the block.
Returns:
True if block exists in hot cache, pending write buffer, or SSD index.
"""
if self._index.contains(block_hash):
return True
# Block may have been evicted from SSD index but still in hot cache
with self._hot_cache_lock:
if block_hash in self._hot_cache:
return True
# Block may be evicted from hot cache and awaiting SSD write
with self._pending_write_hashes_lock:
if block_hash in self._pending_write_buffers:
return True
return False
def preload_matched_blocks(self, block_hashes: list[bytes]) -> int:
"""
Parallel-load matched blocks from SSD into hot cache.
For cold-start optimization: loads blocks that exist on SSD but not
in hot cache, using parallel I/O. After preload, subsequent
load_block() / load_block_with_metadata() calls hit hot cache (~0ms)
instead of SSD (~2ms per block).
Individual block failures are non-fatal (logged and skipped).
Args:
block_hashes: Block hashes confirmed as cache hits.
Returns:
Number of blocks successfully loaded into hot cache.
"""
if not self._hot_cache_enabled:
return 0
if not HAS_MLX:
return 0
# Filter to blocks that need loading: in SSD index but not hot cache
to_load = []
for bh in block_hashes:
metadata = self._index.get(bh)
if metadata is None:
continue
if self._hot_cache_get(bh) is not None:
continue
to_load.append((bh, metadata))
if len(to_load) < 4:
return 0
# Guard: don't preload more than available hot cache capacity.
# If we preload N blocks but hot cache can only hold M < N,
# blocks evict each other and reconstruct_cache falls back to SSD.
# CPD-accepted (GLM L1).
available = self._hot_cache_available_bytes()
if available <= 0:
return 0
capped_to_load: list[tuple[bytes, PagedSSDBlockMetadata]] = []
selected_bytes = 0
for bh, metadata in to_load:
try:
block_bytes = max(0, int(getattr(metadata, "file_size", 0) or 0))
except (TypeError, ValueError):
block_bytes = 0
if block_bytes <= 0:
try:
block_bytes = metadata.file_path.stat().st_size
except OSError:
block_bytes = 0
if selected_bytes + block_bytes > available:
break
capped_to_load.append((bh, metadata))
selected_bytes += block_bytes
to_load = capped_to_load
if len(to_load) < 4:
return 0
# Cap workers to limit peak memory (each load allocates ~122-275MB).
# 8 workers ≈ 1.4GB peak, vs 2.8GB at 16. CPD-accepted (G1/Q3).
start = time.perf_counter()
loaded_count = 0
max_workers = min(8, len(to_load))
def _load_one(block_hash: bytes, metadata: PagedSSDBlockMetadata) -> bool:
file_path = metadata.file_path
if not file_path.exists():
return False
try:
arrays, file_metadata = mx.load(str(file_path), return_metadata=True)
if (
file_metadata
and file_metadata.get("omlx_cache_format_version")
not in _READABLE_CACHE_FORMAT_VERSIONS
):
return False
self._promote_to_hot_cache(block_hash, arrays, file_metadata, metadata)
return True
except Exception as e:
logger.warning(f"Preload failed for block {block_hash.hex()[:16]}: {e}")
return False
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(_load_one, bh, meta): bh for bh, meta in to_load}
for future in as_completed(futures):
try:
if future.result():
loaded_count += 1
except Exception:
pass
elapsed_ms = (time.perf_counter() - start) * 1000
self._stats["preload_calls"] += 1
self._stats["preload_blocks_loaded"] += loaded_count
self._stats["preload_time_ms"] += elapsed_ms
if loaded_count > 0:
logger.info(
f"Preloaded {loaded_count}/{len(to_load)} blocks into hot cache "
f"(workers={max_workers}, time={elapsed_ms:.1f}ms)"
)
return loaded_count
def adopt_layer_signature_if_unset(
self, layer_cache_types: list[str] | None
) -> bool:
"""Adopt ``layer_cache_types`` as the expected signature if none was set.
The scheduler may not be able to derive the post-patch cache layout
before constructing this manager (TurboQuant / MTP / dtype changes
happen at model-load time). Save sites pass the live signature on
every call, so the manager can adopt it the first time it sees one.
Returns True when adoption actually happened (caller can use this
to trigger the one-shot sweep). Returns False when the manager
already had a signature or when ``layer_cache_types`` is empty.
"""
if not layer_cache_types:
return False
if self._expected_layer_cache_types is not None:
return False
canonical = _canonicalize_layer_cache_types(layer_cache_types)
with self._lock:
if self._expected_layer_cache_types is not None:
return False # raced with another adopter
self._expected_layer_cache_types = list(layer_cache_types)
self._signature_sweep_completed = False
logger.info(
"PagedSSDCacheManager adopted layer cache signature "
"(%d layers, %d unique types)",
len(layer_cache_types),
len(set(canonical or ())),
)
return True
def set_expected_layer_signature(
self,
layer_cache_types: list[str] | None,
*,
turboquant_kv_bits: float | None = None,
) -> bool:
"""Set the live layer-cache signature, replacing stale expectations.
Unlike ``adopt_layer_signature_if_unset``, this is used by callers that
learn the final cache layout after manager construction (for example
TurboQuant settings applied by the engine after the scheduler starts).
``turboquant_kv_bits`` is the live TurboQuant bit depth (None when
TurboQuant is inactive). A bit-depth change alone also triggers the
sweep: blocks written at another depth have a different packed state
width and would crash batch concatenation if mixed (#2045).
Returns True when the canonical signature changed and a stale-signature
sweep should run. Returns False for empty input or a canonical no-op.
"""
if not layer_cache_types:
return False
new_signature = list(layer_cache_types)
new_canonical = _canonicalize_layer_cache_types(new_signature)
new_bits = (
float(turboquant_kv_bits) if turboquant_kv_bits is not None else None
)
with self._lock:
old_signature = self._expected_layer_cache_types
old_canonical = _canonicalize_layer_cache_types(old_signature)
bits_changed = new_bits != self._expected_turboquant_kv_bits
if old_canonical == new_canonical and not bits_changed:
if old_signature != new_signature:
self._expected_layer_cache_types = new_signature
return False
self._expected_layer_cache_types = new_signature
self._expected_turboquant_kv_bits = new_bits
self._signature_sweep_completed = False
logger.info(
"PagedSSDCacheManager updated layer cache signature "
"(%d layers, %d unique types, turboquant_kv_bits=%s)",
len(new_signature),
len(set(new_canonical or ())),
new_bits,
)
return True
def invalidate_stale_layer_signature(self) -> int:
"""Drop in-memory index entries whose layer_cache_types — or, when a
TurboQuant depth is expected, whose recorded bit depth — disagree
with the current expected signature.
Scoped to the current ``_expected_model_name``: blocks belonging to
other models share the SSD directory and remain valid for them, so
we leave them alone. Legacy blocks without a recorded ``model_name``
are also skipped — we cannot safely attribute them.
The SSD files are unlinked from the index (and any in-memory hot
copy is dropped), but the on-disk file is left for LRU to reclaim
later. Returns the number of blocks dropped from the index.
Idempotent: a second call after a clean sweep returns 0.
"""
if self._expected_layer_cache_types is None:
return 0
if not self._expected_model_name:
# Without an owning model_name we cannot scope safely; refuse
# rather than risk evicting another model's blocks.
return 0
if self._signature_sweep_completed:
return 0
expected = _canonicalize_layer_cache_types(self._expected_layer_cache_types)
expects_bits = self._expected_turboquant_kv_bits is not None
with self._index._lock:
stale: list[bytes] = []
for h, meta in self._index._index.items():
if not meta.model_name or meta.model_name != self._expected_model_name:
continue
got = _canonicalize_layer_cache_types(meta.layer_cache_types)
if got is None:
# Pre-signature blocks lack the metadata to judge the
# layout. Without a depth expectation, skip rather than
# guess — newer saves will replace them. With one, the
# block can no more prove its packed width than its
# layout, so it is unsafe to keep (see
# _signature_bits_match).
if expects_bits:
stale.append(h)
continue
if got != expected:
stale.append(h)
continue
if not self._signature_bits_match(meta.cache_signature):
stale.append(h)
for h in stale:
self.forget_block(h)
self._signature_sweep_completed = True
if stale:
logger.info(
"Invalidated %d SSD index entries with stale layer "
"cache signature for model %r (kept %d)",
len(stale),
self._expected_model_name,
len(self._index._index),
)
return len(stale)
def forget_block(self, block_hash: bytes) -> bool:
"""
Remove a block from this manager's in-memory indexes without deleting
its SSD file.
Used when a prefix entry points at a block that is incompatible with
the current model/layout. The file may still be valid for another
model sharing the same cache directory.
"""
with self._lock:
removed = self._hot_cache_remove(block_hash) is not None
with self._pending_write_hashes_lock:
if block_hash in self._pending_write_buffers:
removed = True
self._pending_write_buffers.pop(block_hash, None)
self._pending_write_hashes.discard(block_hash)
metadata = self._index.remove(block_hash)
if metadata is not None:
self._incompatible_index.add(metadata)
removed = True
return removed
def delete_block(self, block_hash: bytes) -> bool:
"""
Delete a block from SSD storage.
Args:
block_hash: Content hash for the block.
Returns:
True if deleted successfully.
"""
with self._lock:
# Also remove from hot cache
self._hot_cache_remove(block_hash)
# Also remove from pending write buffer
with self._pending_write_hashes_lock:
self._pending_write_buffers.pop(block_hash, None)
self._pending_write_hashes.discard(block_hash)
metadata = self._index.remove(block_hash)
incompatible_metadata = self._incompatible_index.remove(block_hash)
if metadata is None:
metadata = incompatible_metadata
if metadata is None:
return False
try:
if metadata.file_path.exists():
metadata.file_path.unlink()
logger.debug(f"Deleted SSD cache file: {metadata.file_path}")
return True
except Exception as e:
logger.error(f"Failed to delete SSD cache file: {e}")
return False
# Use at most 99% of available disk space to avoid filling disk completely
_DISK_SAFE_RATIO = 0.99
def _get_effective_max_size(self) -> int:
"""Get effective max size considering actual disk free space.
Returns the minimum of configured max_size and 99% of disk space
available for cache (current cache size + disk free). This ensures
eviction triggers before the disk fills up even when other processes
consume disk space after the server started.
Uses a 30-second TTL cache for shutil.disk_usage() results.
"""
if self._cache_dir is None:
return self._max_size
# Take the lock so a concurrent writer-thread invalidation
# (sets _disk_usage_cache=None on ENOSPC) can't interleave with
# this read-check-write and let one save see a fresh value paired
# with a stale timestamp (or vice versa).
now = time.monotonic()
with self._lock:
if (
self._disk_usage_cache is None
or now - self._disk_usage_cache_time > 30.0
):
try:
self._disk_usage_cache = shutil.disk_usage(self._cache_dir)
except OSError as e:
logger.warning(
f"Failed to check disk usage for SSD cache dir "
f"{self._cache_dir}: {e}"
)
return self._max_size
self._disk_usage_cache_time = now
disk_free = self._disk_usage_cache.free
disk_available = self._tracked_ssd_size() + disk_free
disk_limit = int(disk_available * self._DISK_SAFE_RATIO)
return min(self._max_size, disk_limit)
def _evict_tracked_until_size(
self,
target_size: int,
max_count: int | None = None,
) -> list[tuple[PagedSSDCacheIndex, PagedSSDBlockMetadata]]:
"""Remove oldest tracked SSD entries from their indexes until target."""
evicted: list[tuple[PagedSSDCacheIndex, PagedSSDBlockMetadata]] = []
while self._tracked_ssd_size() > target_size:
if max_count is not None and len(evicted) >= max_count:
break
compatible = self._index.get_lru_entries(1)
incompatible = self._incompatible_index.get_lru_entries(1)
if not compatible and not incompatible:
break
if compatible and incompatible:
if incompatible[0].last_access <= compatible[0].last_access:
source_index = self._incompatible_index
candidate = incompatible[0]
else:
source_index = self._index
candidate = compatible[0]
elif incompatible:
source_index = self._incompatible_index
candidate = incompatible[0]
else:
source_index = self._index
candidate = compatible[0]
metadata = source_index.remove(candidate.block_hash)
if metadata is not None:
evicted.append((source_index, metadata))
return evicted
def _enforce_size_limit_for_new_block(
self,
estimated_new_size: int = 1 * 1024 * 1024,
*,
max_unlinks: int | None = None,
unbounded: bool = False,
) -> None:
"""Enforce size limit before adding a new block.
``estimated_new_size`` should be the actual byte size of the block
about to be inserted. The 1 MiB default is for callers that don't
yet know the size at the time eviction is needed; passing the
actual size avoids cache oscillation around the configured limit.
"""
effective_max = self._get_effective_max_size()
# Warn when disk pressure shrinks effective limit well below configured
# (throttled to once per 60s to avoid log spam)
if effective_max < self._max_size * 0.1:
now = time.monotonic()
if now - self._last_disk_pressure_warn > 60.0:
self._last_disk_pressure_warn = now
logger.warning(
f"SSD cache disk pressure: effective limit "
f"{format_bytes(effective_max)} "
f"(configured {format_bytes(self._max_size)}), "
f"disk nearly full"
)
target_size = effective_max - estimated_new_size
if target_size < 0:
target_size = int(effective_max * 0.9)
max_count = None if unbounded else max_unlinks
if max_count is None and not unbounded:
max_count = _MAX_INLINE_UNLINKS_PER_SAVE
if self._tracked_ssd_size() > target_size:
evicted = self._evict_tracked_until_size(
target_size,
max_count=max_count,
)
# Inline unlinks on the calling thread. Eviction typically returns
# a single entry per save because the tracked LRU walk stops as
# soon as the shared SSD budget is back under target. Inline
# removes bounded-queue contention entirely. Hot cache is NOT
# touched here — ``delete_block()`` is the only path that clears
# both tiers.
#
# Bounded inline burst. The ENOSPC-recovery path invalidates the
# 30 s disk-usage cache, which can shrink the next
# ``_get_effective_max_size`` call sharply. Cap the burst at
# ``_MAX_INLINE_UNLINKS_PER_SAVE`` and leave remaining LRU
# entries in their indexes so subsequent saves drain the rest.
# Bounds per-call latency at the cost of taking multiple saves
# to fully reconverge.
for source_index, metadata in evicted:
self._unlink_evicted(metadata, source_index)
if max_count is not None and len(evicted) >= max_count:
logger.debug(
f"Inline eviction capped at {max_count} entries; "
f"{self._tracked_ssd_size() - target_size} bytes remain "
f"above target for subsequent saves to drain"
)
def enforce_size_limit(self) -> int:
"""
Enforce SSD cache size limit by evicting LRU files.
Returns:
Number of bytes freed.
"""
# Decide what to evict under the lock, but perform unlinks outside
# it: a single unlink on a slow disk (NFS / encrypted FS / ENOSPC
# retry path) can block tens to hundreds of ms, and every
# _get_effective_max_size() / writer-thread cache-invalidation
# contends on self._lock. The index has its own internal lock
# protecting the LRU/size accounting.
with self._lock:
initial_size = self._tracked_ssd_size()
effective_max = self._get_effective_max_size()
if initial_size <= effective_max:
return 0
target_size = int(effective_max * 0.9) # 90% of effective max
evicted = self._evict_tracked_until_size(target_size)
# Do NOT remove from hot cache — see _enforce_size_limit_for_new_block
for source_index, metadata in evicted:
self._unlink_evicted(metadata, source_index)
freed = initial_size - self._tracked_ssd_size()
logger.info(
f"SSD cache size enforcement: freed {format_bytes(freed)}, "
f"evicted {len(evicted)} files"
)
return freed
def _unlink_evicted(
self,
metadata: PagedSSDBlockMetadata,
source_index: PagedSSDCacheIndex | None = None,
) -> None:
"""Delete an evicted block file from disk.
On unlink failure other than FileNotFoundError, re-add the
metadata to the index so ``total_size`` keeps reflecting actual
on-disk bytes; without this, accumulated failures would let the
cache silently exceed ``max_size`` (the index would report free
space that does not exist on disk).
"""
try:
metadata.file_path.unlink(missing_ok=True)
self._stats["evictions"] += 1
except OSError as e:
restore_index = source_index or self._index
# Restore the index entry so total_size matches disk reality.
# The re-added entry lands at the LRU tail (most-recently
# touched), which deprioritises immediate re-eviction.
restore_index.add(metadata)
self._stats["evict_unlink_failures"] += 1
logger.exception(
"Failed to delete evicted SSD cache file %s: %s",
metadata.file_path,
e,
)
def clear_hot_cache(self) -> int:
"""Clear all in-memory (hot) cache entries.
Returns:
Number of entries cleared.
"""
with self._hot_cache_lock:
count = len(self._hot_cache)
self._hot_cache.clear()
self._hot_cache_total_bytes = 0
if self._hot_cache_budget is not None:
self._hot_cache_budget.forget_owner(self)
if count:
logger.info("Cleared %d hot cache entries", count)
return count
def shrink_hot_cache_to(
self,
target_bytes: int,
protected_hashes: set[bytes] | None = None,
) -> int:
"""Shrink this manager's hot cache to ``target_bytes`` by local LRU."""
target_bytes = max(0, int(target_bytes))
protected_hashes = protected_hashes or set()
if self._hot_cache_budget is not None:
return self._hot_cache_budget.shrink_to(
target_bytes, protected_hashes=protected_hashes
)
evicted_entries: list[tuple[bytes, dict, int]] = []
with self._hot_cache_lock:
while self._hot_cache_total_bytes > target_bytes and self._hot_cache:
victim_hash = None
for block_hash in self._hot_cache:
if block_hash not in protected_hashes:
victim_hash = block_hash
break
if victim_hash is None:
break
evicted = self._hot_cache.pop(victim_hash)
size = self._hot_cache_entry_size(evicted)
self._hot_cache_total_bytes = max(0, self._hot_cache_total_bytes - size)
evicted_entries.append((victim_hash, evicted, size))
freed = 0
for block_hash, evicted, size in evicted_entries:
freed += size
self._handle_hot_cache_eviction(block_hash, evicted)
if freed and self._hot_cache_only:
logger.warning(
"Shrank hot-cache-only tier by %s; evicted chains are not "
"persisted to SSD",
format_bytes(freed),
)
elif freed:
logger.info("Shrank hot cache by %s", format_bytes(freed))
return freed
def clear(self) -> int:
"""
Clear all SSD cache files.
Returns:
Number of files deleted.
"""
with self._lock:
count = 0
block_hashes = (
self._index.get_all_hashes() + self._incompatible_index.get_all_hashes()
)
for block_hash in dict.fromkeys(block_hashes):
if self.delete_block(block_hash):
count += 1
logger.info(f"Cleared SSD cache: deleted {count} files")
return count
def get_stats(self) -> PagedSSDCacheStats:
"""
Get SSD cache statistics.
Returns:
PagedSSDCacheStats with cache metrics.
"""
with self._lock:
with self._hot_cache_lock:
hot_entries = len(self._hot_cache)
hot_size = self._hot_cache_total_bytes
return PagedSSDCacheStats(
hits=self._stats["hits"],
misses=self._stats["misses"],
evictions=self._stats["evictions"],
saves=self._stats["saves"],
saves_persisted=self._stats["saves_persisted"],
loads=self._stats["loads"],
errors=self._stats["errors"],
evict_unlink_failures=self._stats["evict_unlink_failures"],
total_size_bytes=self._tracked_ssd_size(),
max_size_bytes=self._get_effective_max_size(),
configured_max_size_bytes=self._max_size,
num_files=self._tracked_ssd_count(),
hot_cache_entries=hot_entries,
hot_cache_size_bytes=hot_size,
hot_cache_max_bytes=self._effective_hot_cache_max_bytes(),
hot_cache_hits=self._stats["hot_cache_hits"],
hot_cache_evictions=self._stats["hot_cache_evictions"],
hot_cache_promotions=self._stats["hot_cache_promotions"],
ssd_write_drops=self._stats["ssd_write_drops"],
ssd_inline_write_fallbacks=self._stats["ssd_inline_write_fallbacks"],
)
def get_stats_for_model(self, model_name: str) -> PagedSSDCacheStats:
"""Get model-scoped SSD cache statistics.
The SSD cache directory can be shared across multiple loaded models, so
dashboard per-model rows must be filtered by block metadata rather than
reusing the global cache totals.
"""
normalized_name = model_name.rstrip("/")
basename = os.path.basename(normalized_name) if normalized_name else ""
def _matches(candidate: str) -> bool:
candidate = candidate.rstrip("/")
if not candidate:
return False
if candidate == normalized_name:
return True
if basename and os.path.basename(candidate) == basename:
return True
return False
with self._lock:
indexed_entries = [
metadata
for metadata in self._index.get_all_metadata()
if _matches(metadata.model_name)
]
indexed_size = sum(metadata.file_size for metadata in indexed_entries)
indexed_count = len(indexed_entries)
with self._hot_cache_lock:
hot_entries = []
hot_size = 0
for entry in self._hot_cache.values():
blk_meta = entry.get("block_metadata")
if blk_meta is None or not _matches(blk_meta.model_name):
continue
hot_entries.append(entry)
hot_size += self._hot_cache_entry_size(entry)
return PagedSSDCacheStats(
hits=self._stats["hits"],
misses=self._stats["misses"],
evictions=self._stats["evictions"],
saves=self._stats["saves"],
saves_persisted=self._stats["saves_persisted"],
loads=self._stats["loads"],
errors=self._stats["errors"],
evict_unlink_failures=self._stats["evict_unlink_failures"],
total_size_bytes=indexed_size,
max_size_bytes=self._get_effective_max_size(),
configured_max_size_bytes=self._max_size,
num_files=indexed_count,
hot_cache_entries=len(hot_entries),
hot_cache_size_bytes=hot_size,
hot_cache_max_bytes=self._effective_hot_cache_max_bytes(),
hot_cache_hits=self._stats["hot_cache_hits"],
hot_cache_evictions=self._stats["hot_cache_evictions"],
hot_cache_promotions=self._stats["hot_cache_promotions"],
ssd_write_drops=self._stats["ssd_write_drops"],
ssd_inline_write_fallbacks=self._stats["ssd_inline_write_fallbacks"],
)
def get_stats_dict(self) -> dict[str, Any]:
"""
Get SSD cache statistics as a dictionary.
This method provides the legacy dictionary format for compatibility.
Returns:
Dictionary with cache statistics.
"""
with self._lock:
with self._hot_cache_lock:
hot_entries = len(self._hot_cache)
hot_size = self._hot_cache_total_bytes
effective_max = self._get_effective_max_size()
return {
"cache_dir": str(self._cache_dir) if self._cache_dir else "None",
"max_size": effective_max,
"max_size_formatted": format_bytes(effective_max),
"configured_max_size": self._max_size,
"configured_max_size_formatted": format_bytes(self._max_size),
"total_size": self._tracked_ssd_size(),
"total_size_formatted": format_bytes(self._tracked_ssd_size()),
"utilization": (
self._tracked_ssd_size() / effective_max
if effective_max > 0
else 0.0
),
"num_files": self._tracked_ssd_count(),
"hot_cache_entries": hot_entries,
"hot_cache_size_bytes": hot_size,
"hot_cache_max_bytes": self._effective_hot_cache_max_bytes(),
"hot_cache_size_formatted": format_bytes(hot_size),
"hot_cache_max_formatted": format_bytes(
self._effective_hot_cache_max_bytes()
),
**self._stats,
}
def close(self) -> None:
"""Close the SSD cache manager, flushing hot cache and pending writes."""
logger.info("Shutting down PagedSSDCacheManager...")
# Flush hot cache entries to SSD before shutdown.
# Dirty blocks wait for queue space first; sustained saturation falls
# back to an inline write on this thread.
if self._hot_cache_enabled:
with self._hot_cache_lock:
entries_to_flush = list(self._hot_cache.items())
flushed = 0
failed = 0
for block_hash, entry in entries_to_flush:
if self._writer_thread and not self._writer_thread.is_alive():
logger.warning(
"Writer thread died during shutdown flush, "
f"aborting ({flushed} flushed, "
f"{len(entries_to_flush) - flushed - failed} remaining)"
)
break
blk_meta = entry.get("block_metadata")
if not entry.get("dirty", True):
continue
if blk_meta and blk_meta.file_path.exists():
continue
if self._enqueue_ssd_write(block_hash, entry, blocking=True):
flushed += 1
else:
failed += 1
if flushed:
logger.info(f"Flushed {flushed} hot cache blocks to SSD")
if failed:
logger.warning(f"Failed to flush {failed} hot cache blocks")
# Signal writer thread to stop (after processing remaining queue)
if self._writer_thread:
self._writer_shutdown.set()
# Send sentinel to unblock the writer if it's waiting on the queue
try:
self._write_queue.put_nowait(None)
except queue.Full:
pass # Writer will check shutdown flag on next iteration
# Wait for writer to finish — longer timeout to allow flush
timeout = 120 if self._hot_cache_enabled else 60
self._writer_thread.join(timeout=timeout)
if self._writer_thread.is_alive():
logger.warning(
f"SSD cache writer thread did not stop within {timeout}s"
)
# Clear hot cache and pending write buffer
with self._hot_cache_lock:
self._hot_cache.clear()
self._hot_cache_total_bytes = 0
if self._hot_cache_budget is not None:
self._hot_cache_budget.forget_owner(self)
with self._pending_write_hashes_lock:
self._pending_write_buffers.clear()
self._pending_write_hashes.clear()
logger.debug("PagedSSDCacheManager closed")
def __repr__(self) -> str:
return (
f"PagedSSDCacheManager(dir={self._cache_dir}, "
f"size={format_bytes(self._tracked_ssd_size())}/"
f"{format_bytes(self._max_size)}, "
f"files={self._tracked_ssd_count()})"
)
# =========================================================================
# CacheManager ABC Interface Implementation
# =========================================================================
def fetch(self, key: Any) -> tuple[Any | None, bool]:
"""
Fetch a cached block from SSD storage.
Args:
key: Block hash (bytes) to look up.
Returns:
Tuple of (cache_data, True) if found, (None, False) otherwise.
"""
if not isinstance(key, bytes):
return None, False
cache_data = self.load_block(key)
if cache_data is not None:
return cache_data, True
return None, False
def store(self, key: Any, value: Any) -> bool:
"""
Store a block in SSD cache.
Args:
key: Block hash (bytes).
value: Tuple of (cache_data, token_count) or just cache_data.
Returns:
True if stored successfully.
"""
if not isinstance(key, bytes):
return False
if isinstance(value, tuple) and len(value) >= 2:
cache_data, token_count = value[0], value[1]
model_name = value[2] if len(value) > 2 else ""
else:
cache_data = value
token_count = 0
model_name = ""
return self.save_block(key, cache_data, token_count, model_name)
def evict(self, key: Any) -> bool:
"""
Evict a specific block from SSD cache.
Args:
key: Block hash (bytes) to evict.
Returns:
True if evicted, False if not found.
"""
if not isinstance(key, bytes):
return False
return self.delete_block(key)
@property
def size(self) -> int:
"""
Get the current number of cached blocks.
Returns:
Number of cached blocks.
"""
return self._index.count
@property
def max_size(self) -> int:
"""
Get the effective maximum cache size in bytes.
This accounts for actual disk free space, returning the minimum of
the configured max size and 99% of available disk space for cache.
Returns:
Effective maximum cache size in bytes.
"""
return self._get_effective_max_size()
@property
def configured_max_size(self) -> int:
"""
Get the originally configured maximum cache size in bytes.
Returns:
Configured maximum cache size in bytes.
"""
return self._max_size