Skip to content

Commit f6dda38

Browse files
committed
feat(cache): implement HybridCheckpointCache for hybrid/recurrent models
Introduces a dedicated caching mechanism to support state rollback for models that cannot physically truncate their KV cache (e.g., Qwen3-Next, Qwen3.5, etc.). Key additions and changes: - Add `HybridCheckpoint` dataclass to store RNN state snapshots along with their binary data and metadata. - Implement `HybridCheckpointCache` to manage sequence-specific states using the `llama_state_seq_*_ext` C++ APIs. - Introduce `_hash_prefix` using SHA-256 to guarantee cryptographic certainty when matching prompt histories, preventing state corruption. - Add `save_checkpoint` with a FIFO eviction policy to strictly bound memory usage based on `max_checkpoints`. - Add `restore_checkpoint` to securely inject valid RNN states back into the C++ backend. - Explicitly disable incompatible dictionary interfaces (`__getitem__`, `__setitem__`, `__contains__`) inherited from `BaseLlamaCache`. - Refactor module imports (alphabetical sorting) and relocate `LlamaDiskCache` for better structural consistency.
1 parent e230ebb commit f6dda38

1 file changed

Lines changed: 219 additions & 48 deletions

File tree

llama_cpp/llama_cache.py

Lines changed: 219 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import sys
21
from abc import ABC, abstractmethod
2+
from collections import OrderedDict
3+
import ctypes
4+
from dataclasses import dataclass
5+
import diskcache
6+
import hashlib
7+
import sys
38
from typing import (
9+
Any,
10+
List,
411
Optional,
512
Sequence,
613
Tuple,
714
)
8-
from collections import OrderedDict
9-
10-
import diskcache
1115

1216
import llama_cpp.llama
17+
import llama_cpp._internals as _internals
18+
import llama_cpp.llama_cpp as llama_cpp
1319

1420
from .llama_types import *
1521

@@ -46,6 +52,60 @@ def __setitem__(
4652
raise NotImplementedError
4753

4854

55+
class LlamaDiskCache(BaseLlamaCache):
56+
"""Cache for a llama.cpp model using disk."""
57+
58+
def __init__(
59+
self, cache_dir: str = ".cache/llama_cache", capacity_bytes: int = (2 << 30)
60+
):
61+
super().__init__(capacity_bytes)
62+
self.cache = diskcache.Cache(cache_dir)
63+
64+
@property
65+
def cache_size(self):
66+
return int(self.cache.volume()) # type: ignore
67+
68+
def _find_longest_prefix_key(
69+
self,
70+
key: Tuple[int, ...],
71+
) -> Optional[Tuple[int, ...]]:
72+
min_len = 0
73+
min_key: Optional[Tuple[int, ...]] = None
74+
for k in self.cache.iterkeys(): # type: ignore
75+
prefix_len = llama_cpp.llama.Llama.longest_token_prefix(k, key)
76+
if prefix_len > min_len:
77+
min_len = prefix_len
78+
min_key = k # type: ignore
79+
return min_key
80+
81+
def __getitem__(self, key: Sequence[int]) -> "llama_cpp.llama.LlamaState":
82+
key = tuple(key)
83+
_key = self._find_longest_prefix_key(key)
84+
if _key is None:
85+
raise KeyError("Key not found")
86+
value: "llama_cpp.llama.LlamaState" = self.cache.pop(_key) # type: ignore
87+
# NOTE: This puts an integer as key in cache, which breaks,
88+
# Llama.longest_token_prefix(k, key) above since k is not a tuple of ints/tokens
89+
# self.cache.push(_key, side="front") # type: ignore
90+
return value
91+
92+
def __contains__(self, key: Sequence[int]) -> bool:
93+
return self._find_longest_prefix_key(tuple(key)) is not None
94+
95+
def __setitem__(self, key: Sequence[int], value: "llama_cpp.llama.LlamaState"):
96+
print("LlamaDiskCache.__setitem__: called", file=sys.stderr)
97+
key = tuple(key)
98+
if key in self.cache:
99+
print("LlamaDiskCache.__setitem__: delete", file=sys.stderr)
100+
del self.cache[key]
101+
self.cache[key] = value
102+
print("LlamaDiskCache.__setitem__: set", file=sys.stderr)
103+
while self.cache_size > self.capacity_bytes and len(self.cache) > 0:
104+
key_to_remove = next(iter(self.cache))
105+
del self.cache[key_to_remove]
106+
print("LlamaDiskCache.__setitem__: trim", file=sys.stderr)
107+
108+
49109
class LlamaRAMCache(BaseLlamaCache):
50110
"""Cache for a llama.cpp model using RAM."""
51111

@@ -259,55 +319,166 @@ def __setitem__(self, key: Sequence[int], value: "llama_cpp.llama.LlamaState"):
259319
LlamaCache = LlamaRAMCache
260320

261321

262-
class LlamaDiskCache(BaseLlamaCache):
263-
"""Cache for a llama.cpp model using disk."""
322+
@dataclass
323+
class HybridCheckpoint:
324+
"""Represents a single snapshot of the RNN/Hybrid model's hidden state."""
325+
pos: int # The token position (cursor) where this snapshot was taken
326+
data: bytes # The raw binary RNN state data
327+
hash_val: str # SHA-256 hash of the token prefix to ensure exact sequence matching
328+
size: int # Size of the state data in bytes
329+
seq_id: int # Sequence ID this checkpoint belongs to
264330

265-
def __init__(
266-
self, cache_dir: str = ".cache/llama_cache", capacity_bytes: int = (2 << 30)
267-
):
268-
super().__init__(capacity_bytes)
269-
self.cache = diskcache.Cache(cache_dir)
331+
class HybridCheckpointCache(BaseLlamaCache):
332+
"""
333+
Manager for RNN state snapshots (Checkpoints) tailored for Hybrid/Recurrent models.
334+
Provides rollback capabilities for models that cannot physically truncate KV cache.
335+
"""
336+
def __init__(self, ctx: llama_cpp.llama_context_p, seq_id: int = 0, max_checkpoints: int = 16, verbose: bool = False):
337+
if ctx is None:
338+
raise ValueError("HybridCheckpointCache: Failed to create HybridCheckpointCache with model context")
339+
self._ctx = ctx
340+
self.seq_id = seq_id
341+
self.max_checkpoints = max_checkpoints
342+
self.checkpoints: list[HybridCheckpoint] = []
343+
self._current_size = 0
344+
345+
# Cache C-type API function pointers for performance
346+
self._get_size_ext = llama_cpp.llama_state_seq_get_size_ext
347+
self._get_data_ext = llama_cpp.llama_state_seq_get_data_ext
348+
self._set_data_ext = llama_cpp.llama_state_seq_set_data_ext
349+
self._flag_partial = llama_cpp.LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY
350+
351+
self.verbose = verbose
270352

271353
@property
272-
def cache_size(self):
273-
return int(self.cache.volume()) # type: ignore
354+
def cache_size(self) -> int:
355+
"""Returns the total memory used by all stored checkpoints in bytes."""
356+
return self._current_size
274357

275-
def _find_longest_prefix_key(
358+
def clear(self):
359+
"""Clears all stored checkpoints and resets memory tracking."""
360+
self.checkpoints.clear()
361+
self._current_size = 0
362+
if self.verbose:
363+
print("HybridCheckpointCache: cleared")
364+
365+
# Helper tools
366+
367+
def _hash_prefix(self, tokens: List[int], length: int) -> str:
368+
"""
369+
Computes a SHA-256 hash for a sequence of tokens up to the specified length.
370+
This ensures that checkpoints are only restored for the EXACT same conversation history.
371+
"""
372+
if length <= 0:
373+
return "empty"
374+
tokens_size = len(tokens)
375+
if length > tokens_size:
376+
length = tokens_size
377+
data = bytes(tokens[:length])
378+
return hashlib.sha256(data).hexdigest()[:32]
379+
380+
def find_best_checkpoint(self, tokens: List[int], seq_id: int = 0) -> Optional[HybridCheckpoint]:
381+
"""
382+
Finds the longest valid checkpoint that perfectly matches the provided token prefix.
383+
Returns None if no matching checkpoint is found.
384+
"""
385+
best_cp = None
386+
best_pos = -1
387+
for cp in self.checkpoints:
388+
if cp.seq_id != seq_id or cp.pos > len(tokens):
389+
# Skip if sequence ID mismatches or checkpoint is longer than the current prompt
390+
continue
391+
392+
# Verify cryptographic integrity of the prompt history
393+
if self._hash_prefix(tokens, cp.pos) == cp.hash_val:
394+
if cp.pos > best_pos:
395+
# Keep the checkpoint with the longest matching prefix (highest pos)
396+
best_pos = cp.pos
397+
best_cp = cp
398+
return best_cp
399+
400+
def save_checkpoint(
276401
self,
277-
key: Tuple[int, ...],
278-
) -> Optional[Tuple[int, ...]]:
279-
min_len = 0
280-
min_key: Optional[Tuple[int, ...]] = None
281-
for k in self.cache.iterkeys(): # type: ignore
282-
prefix_len = llama_cpp.llama.Llama.longest_token_prefix(k, key)
283-
if prefix_len > min_len:
284-
min_len = prefix_len
285-
min_key = k # type: ignore
286-
return min_key
402+
current_pos: int,
403+
tokens: List[int],
404+
seq_id: int = 0
405+
) -> bool:
406+
"""
407+
Extracts the RNN hidden state from the C++ backend and saves it as a checkpoint.
408+
Manages eviction (FIFO) if the maximum number of checkpoints is exceeded.
409+
"""
410+
flags = self._flag_partial
411+
412+
# 1. Query the required buffer size
413+
size = llama_cpp.llama_state_seq_get_size_ext(self._ctx, seq_id, flags)
414+
if size == 0:
415+
if self.verbose:
416+
print("HybridCheckpointCache: size=0, skip")
417+
return False
418+
419+
# 2. Allocate buffer and extract data
420+
buffer = (ctypes.c_uint8 * size)()
421+
n_written = llama_cpp.llama_state_seq_get_data_ext(self._ctx, buffer, size, seq_id, flags)
422+
if n_written != size:
423+
if self.verbose:
424+
print(f"HybridCheckpointCache: get failed {n_written}/{size}")
425+
return False
426+
427+
data_bytes = bytes(buffer[:n_written])
428+
hash_val = self._hash_prefix(tokens, current_pos)
429+
430+
# 3. Store the checkpoint
431+
self.checkpoints.append(HybridCheckpoint(
432+
pos=current_pos,
433+
data=data_bytes,
434+
hash_val=hash_val,
435+
size=n_written,
436+
seq_id=seq_id)
437+
)
438+
self._current_size += n_written
287439

288-
def __getitem__(self, key: Sequence[int]) -> "llama_cpp.llama.LlamaState":
289-
key = tuple(key)
290-
_key = self._find_longest_prefix_key(key)
291-
if _key is None:
292-
raise KeyError("Key not found")
293-
value: "llama_cpp.llama.LlamaState" = self.cache.pop(_key) # type: ignore
294-
# NOTE: This puts an integer as key in cache, which breaks,
295-
# Llama.longest_token_prefix(k, key) above since k is not a tuple of ints/tokens
296-
# self.cache.push(_key, side="front") # type: ignore
297-
return value
440+
# 4. Enforce capacity limits (FIFO eviction)
441+
while len(self.checkpoints) > self.max_checkpoints:
442+
if not self.checkpoints:
443+
break
444+
old_cp = self.checkpoints.pop(0)
445+
self._current_size -= old_cp.size
446+
if self.verbose:
447+
print(f"HybridCheckpointCache: evicted pos={old_cp.pos}")
298448

299-
def __contains__(self, key: Sequence[int]) -> bool:
300-
return self._find_longest_prefix_key(tuple(key)) is not None
449+
if self.verbose:
450+
print(f"HybridCheckpointCache: Saved checkpoint at pos {current_pos} ({size / 1024 / 1024:.2f} MiB) "
451+
f"total={len(self.checkpoints)} used={self._current_size / 1024 / 1024:.2f} MiB",
452+
file=sys.stderr)
301453

302-
def __setitem__(self, key: Sequence[int], value: "llama_cpp.llama.LlamaState"):
303-
print("LlamaDiskCache.__setitem__: called", file=sys.stderr)
304-
key = tuple(key)
305-
if key in self.cache:
306-
print("LlamaDiskCache.__setitem__: delete", file=sys.stderr)
307-
del self.cache[key]
308-
self.cache[key] = value
309-
print("LlamaDiskCache.__setitem__: set", file=sys.stderr)
310-
while self.cache_size > self.capacity_bytes and len(self.cache) > 0:
311-
key_to_remove = next(iter(self.cache))
312-
del self.cache[key_to_remove]
313-
print("LlamaDiskCache.__setitem__: trim", file=sys.stderr)
454+
return True
455+
456+
def restore_checkpoint(self, cp: HybridCheckpoint, seq_id: int = 0) -> bool:
457+
"""
458+
Injects a previously saved RNN state checkpoint back into the C++ backend memory.
459+
"""
460+
if cp.seq_id != seq_id:
461+
return False
462+
flags = self._flag_partial
463+
464+
# Copy data back to a ctypes buffer and push to backend
465+
buffer = (ctypes.c_uint8 * cp.size).from_buffer_copy(cp.data)
466+
ret = llama_cpp.llama_state_seq_set_data_ext(
467+
self._ctx, buffer, cp.size, seq_id, flags
468+
)
469+
success = (ret == cp.size)
470+
471+
if self.verbose:
472+
print(f"HybridCheckpointCache: restore {'OK' if success else 'FAIL'} pos={cp.pos}")
473+
return success
474+
475+
# Disable BaseLlamaCache Dictionary Interfaces
476+
477+
def __getitem__(self, key):
478+
raise NotImplementedError("HybridCheckpointCache: pls use save_checkpoint or restore_checkpoint method")
479+
480+
def __setitem__(self, key, value):
481+
raise NotImplementedError("HybridCheckpointCache: pls use save_checkpoint or restore_checkpoint method")
482+
483+
def __contains__(self, key):
484+
raise NotImplementedError("HybridCheckpointCache: pls use save_checkpoint or restore_checkpoint method")

0 commit comments

Comments
 (0)