# 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(" 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