Files
2026-04-21 16:41:49 -07:00

1764 lines
57 KiB
Python

# Copyright © 2023-2024 Apple Inc.
import copy
from collections import deque
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
from .base import create_causal_mask
def make_prompt_cache(
model: nn.Module,
max_kv_size: Optional[int] = None,
) -> List[Any]:
"""
Construct the model's cache for use in generation.
This function will defer the cache construction to the model if it has a
``make_cache`` method, otherwise it will make a default KV cache.
Args:
model (nn.Module): The language model.
max_kv_size (Optional[int]): If provided and the model does not have a
``make_cache`` method, a ``RotatingKVCache`` is used with a maximum
size of ``max_kv_size``
"""
if hasattr(model, "make_cache"):
return model.make_cache()
num_layers = len(model.layers)
if max_kv_size is not None:
return [
RotatingKVCache(max_size=max_kv_size, keep=4) for _ in range(num_layers)
]
else:
return [KVCache() for _ in range(num_layers)]
def save_prompt_cache(file_name: str, cache: List[Any], metadata: Dict[str, str] = {}):
"""
Save a pre-computed prompt cache to a file.
Args:
file_name (str): The ``.safetensors`` file name.
cache (List[Any]): The model state.
metadata (Dict[str, str]): Optional metadata to save along with model
state.
"""
cache_data = [c.state for c in cache]
cache_info = [c.meta_state for c in cache]
cache_data = dict(tree_flatten(cache_data))
cache_classes = [type(c).__name__ for c in cache]
cache_metadata = [cache_info, metadata, cache_classes]
cache_metadata = dict(tree_flatten(cache_metadata))
mx.save_safetensors(file_name, cache_data, cache_metadata)
def load_prompt_cache(file_name, return_metadata=False):
"""
Load a prompt cache from a file.
Args:
file_name (str): The ``.safetensors`` file name.
return_metadata (bool): Whether or not to return metadata.
Default: ``False``.
Returns:
List[Any] or Tuple[List[Any], Dict[str, str]]: The prompt cache and
the metadata if requested.
"""
arrays, cache_metadata = mx.load(file_name, return_metadata=True)
arrays = tree_unflatten(list(arrays.items()))
cache_metadata = tree_unflatten(list(cache_metadata.items()))
info, metadata, classes = cache_metadata
cache = [
globals()[c].from_state(state, meta_state)
for c, state, meta_state in zip(classes, arrays, info)
]
if return_metadata:
return cache, metadata
return cache
def can_trim_prompt_cache(cache: List[Any]) -> bool:
"""
Check if model's cache can be trimmed.
"""
return all(c.is_trimmable() for c in cache)
def trim_prompt_cache(cache: List[Any], num_tokens: int) -> List[Any]:
"""
Trim the model's cache by the given number of tokens.
This function will trim the cache if possible (in-place) and return the
number of tokens that were trimmed.
Args:
cache (List[Any]): The model's cache.
num_tokens (int): The number of tokens to trim.
Returns:
(int): The number of tokens that were trimmed.
"""
if not can_trim_prompt_cache(cache) or len(cache) == 0:
return 0
return [c.trim(num_tokens) for c in cache][0]
def create_attention_mask(
N: int, offset: int, return_array: bool, window_size: Optional[int]
):
if window_size is not None:
return create_causal_mask(N, offset, window_size=window_size)
elif N == 1:
return None
elif return_array:
return create_causal_mask(N, offset, window_size=window_size)
else:
return "causal"
class _BaseCache:
@property
def state(self):
return []
@state.setter
def state(self, v):
if v is not None and v:
raise ValueError("This cache has no state but a state was set.")
@property
def meta_state(self):
return ""
@meta_state.setter
def meta_state(self, v):
if v is not None and v:
raise ValueError("This cache has no meta_state but a meta_state was set.")
def is_trimmable(self):
return False
def size(self):
"""
Return the size (i.e. sequence length) of the cache.
Not every cache is required to implement this, in which case the size
will always be 0 (though the cache may not be empty).
"""
return 0
@property
def nbytes(self):
"""Return the size of this cache in bytes"""
raise NotImplementedError("Cache sub-class must implement nbytes")
def empty(self):
"""
Return if the cache is empty or not.
"""
raise NotImplementedError("Cache sub-class must implement this.")
@classmethod
def from_state(cls, state, meta_state):
# Create an instance of cls without calling __init__
obj = cls.__new__(cls)
obj.state = state
obj.meta_state = meta_state
return obj
class ConcatenateKVCache(_BaseCache):
"""ConcatenateKVCache the simplest KV cache implementation.
Can be used as a mock KV cache or when large blocks are being processed at
a time in which case KVCache isn't necessarily faster. Consider using the
KVCache with a larger step size before using this cache.
"""
def __init__(self):
self.keys = None
self.values = None
self.offset = 0
def update_and_fetch(self, keys, values):
if self.keys is None:
self.keys = keys
self.values = values
else:
self.keys = mx.concatenate([self.keys, keys], axis=-2)
self.values = mx.concatenate([self.values, values], axis=-2)
self.offset = self.keys.shape[-2]
return self.keys, self.values
@property
def state(self):
return self.keys, self.values
@state.setter
def state(self, v):
self.keys, self.values = v
self.offset = self.keys.shape[-2]
def is_trimmable(self):
return True
def trim(self, n):
n = min(self.offset, n)
self.offset -= n
return n
def make_mask(self, *args, **kwargs):
return create_attention_mask(*args, offset=self.offset, **kwargs)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class QuantizedKVCache(_BaseCache):
step = 256
def __init__(self, group_size: int = 64, bits: int = 8):
self.keys = None
self.values = None
self.offset = 0
self.group_size = group_size
self.bits = bits
def update_and_fetch(self, keys, values):
B, n_kv_heads, num_steps, k_head_dim = keys.shape
v_head_dim = values.shape[-1]
prev = self.offset
if self.keys is None or (prev + num_steps) > self.keys[0].shape[-2]:
el_per_int = 8 * mx.uint32.size // self.bits
new_steps = (self.step + num_steps - 1) // self.step * self.step
shape = (B, n_kv_heads, new_steps)
def init_quant(dim):
return (
mx.zeros((*shape, dim // el_per_int), dtype=mx.uint32),
mx.zeros((*shape, dim // self.group_size), dtype=keys.dtype),
mx.zeros((*shape, dim // self.group_size), dtype=keys.dtype),
)
def expand_quant(x):
new_x = mx.zeros((*shape, x.shape[-1]), dtype=x.dtype)
return mx.concatenate([x, new_x], axis=-2)
if self.keys is not None:
if prev % self.step != 0:
self.keys, self.values = tree_map(
lambda x: x[..., :prev, :], (self.keys, self.values)
)
self.keys, self.values = tree_map(
expand_quant, (self.keys, self.values)
)
else:
self.keys, self.values = init_quant(k_head_dim), init_quant(v_head_dim)
self.offset += num_steps
keys = mx.quantize(keys, group_size=self.group_size, bits=self.bits)
values = mx.quantize(values, group_size=self.group_size, bits=self.bits)
for i in range(len(self.keys)):
self.keys[i][..., prev : self.offset, :] = keys[i]
self.values[i][..., prev : self.offset, :] = values[i]
return tree_map(lambda x: x[..., : self.offset, :], (self.keys, self.values))
@property
def state(self):
if self.offset == self.keys[0].shape[2]:
return self.keys, self.values
else:
return tree_map(
lambda x: x[..., : self.offset, :], (self.keys, self.values)
)
@state.setter
def state(self, v):
self.keys, self.values = v
@property
def meta_state(self):
return tuple(map(str, (self.offset, self.group_size, self.bits)))
@meta_state.setter
def meta_state(self, v):
self.offset, self.group_size, self.bits = map(int, v)
def is_trimmable(self):
return True
def trim(self, n):
n = min(self.offset, n)
self.offset -= n
return n
def make_mask(self, *args, **kwargs):
return create_attention_mask(*args, offset=self.offset, **kwargs)
def empty(self):
return self.keys is None
@property
def nbytes(self):
return tree_reduce(lambda a, x: a + x.nbytes, (self.keys, self.values), 0)
class KVCache(_BaseCache):
step = 256
def __init__(self):
self.keys = None
self.values = None
self.offset = 0
def update_and_fetch(self, keys, values):
prev = self.offset
if self.keys is None or (prev + keys.shape[2]) > self.keys.shape[2]:
B, n_kv_heads, _, k_head_dim = keys.shape
v_head_dim = values.shape[3]
n_steps = (self.step + keys.shape[2] - 1) // self.step
k_shape = (B, n_kv_heads, n_steps * self.step, k_head_dim)
v_shape = (B, n_kv_heads, n_steps * self.step, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
if prev % self.step != 0:
self.keys = self.keys[..., :prev, :]
self.values = self.values[..., :prev, :]
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self.offset += keys.shape[2]
self.keys[..., prev : self.offset, :] = keys
self.values[..., prev : self.offset, :] = values
return self.keys[..., : self.offset, :], self.values[..., : self.offset, :]
def size(self):
return self.offset
@property
def state(self):
if self.offset == self.keys.shape[2]:
return self.keys, self.values
else:
return (
self.keys[..., : self.offset, :],
self.values[..., : self.offset, :],
)
@state.setter
def state(self, v):
self.keys, self.values = v
self.offset = self.keys.shape[2]
def is_trimmable(self):
return True
def trim(self, n):
n = min(self.offset, n)
self.offset -= n
return n
def to_quantized(self, group_size: int = 64, bits: int = 4) -> QuantizedKVCache:
quant_cache = QuantizedKVCache(group_size=group_size, bits=bits)
quant_cache.offset = self.offset
if self.keys is not None:
quant_cache.keys = mx.quantize(self.keys, group_size=group_size, bits=bits)
quant_cache.values = mx.quantize(
self.values, group_size=group_size, bits=bits
)
return quant_cache
def make_mask(self, *args, **kwargs):
return create_attention_mask(*args, offset=self.offset, **kwargs)
@classmethod
def merge(_, caches):
return BatchKVCache.merge(caches)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class RotatingKVCache(_BaseCache):
step = 256
def __init__(self, max_size, keep=0):
self.keep = keep
self.keys = None
self.values = None
self.offset = 0
self.max_size = max_size
self._idx = 0
def _trim(self, trim_size, v, append=None):
to_cat = []
if trim_size > 0:
to_cat = [v[..., : self.keep, :], v[..., trim_size + self.keep :, :]]
else:
to_cat = [v]
if append is not None:
to_cat.append(append)
return mx.concatenate(to_cat, axis=2)
def _temporal_order(self, v):
"""
Rearrange the cache into temporal order, slicing off the end if unused.
"""
if self._idx == v.shape[2]:
return v
elif self._idx < self.offset:
return mx.concatenate(
[
v[..., : self.keep, :],
v[..., self._idx :, :],
v[..., self.keep : self._idx, :],
],
axis=2,
)
else:
return v[..., : self._idx, :]
def _update_concat(self, keys, values):
if self.keys is None:
self.keys = keys
self.values = values
else:
# Put the keys/values in temporal order to
# preserve context
self.keys = self._temporal_order(self.keys)
self.values = self._temporal_order(self.values)
self._idx = self.keys.shape[2]
# The largest size is self.max_size + S - 1 to ensure
# every token gets at least self.max_size context
trim_size = self._idx - self.max_size + 1
self.keys = self._trim(trim_size, self.keys, keys)
self.values = self._trim(trim_size, self.values, values)
self.offset += keys.shape[2]
self._idx = self.keys.shape[2]
return self.keys, self.values
def _update_in_place(self, keys, values):
# May not have hit the max size yet, so potentially
# keep growing the cache
B, n_kv_heads, S, k_head_dim = keys.shape
prev = self.offset
if self.keys is None or (
prev >= self.keys.shape[2] and self.keys.shape[2] < self.max_size
):
v_head_dim = values.shape[3]
new_size = min(self.step, self.max_size - prev)
k_shape = (B, n_kv_heads, new_size, k_head_dim)
v_shape = (B, n_kv_heads, new_size, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self._idx = prev
# Trim if needed
trim_size = self.keys.shape[2] - self.max_size
if trim_size > 0:
self.keys = self._trim(trim_size, self.keys)
self.values = self._trim(trim_size, self.values)
self._idx = self.max_size
# Rotate
if self._idx == self.max_size:
self._idx = self.keep
# Assign
self.keys[..., self._idx : self._idx + S, :] = keys
self.values[..., self._idx : self._idx + S, :] = values
self.offset += S
self._idx += S
# If the buffer is not full, slice off the end
if self.offset < self.max_size:
return self.keys[..., : self.offset, :], self.values[..., : self.offset, :]
return self.keys, self.values
def update_and_fetch(self, keys, values):
if keys.shape[2] == 1:
return self._update_in_place(keys, values)
return self._update_concat(keys, values)
def size(self):
return min(self.offset, self.max_size)
@property
def state(self):
if self.offset < self.keys.shape[2]:
return self.keys[..., : self.offset, :], self.values[..., : self.offset, :]
else:
return self.keys, self.values
@state.setter
def state(self, v):
self.keys, self.values = v
@property
def meta_state(self):
return tuple(map(str, (self.keep, self.max_size, self.offset, self._idx)))
@meta_state.setter
def meta_state(self, v):
self.keep, self.max_size, self.offset, self._idx = map(
int,
v,
)
def is_trimmable(self):
return self.offset < self.max_size
def trim(self, n):
n = min(self.offset, n)
self.offset -= n
self._idx -= n
return n
def to_quantized(self, group_size: int = 64, bits: int = 4) -> QuantizedKVCache:
raise NotImplementedError("RotatingKVCache Quantization NYI")
def make_mask(
self, N: int, window_size: Optional[int] = None, return_array: bool = False
):
if N > 1:
window_size = window_size or self.max_size
offset = min(self.max_size - 1, self.offset)
if offset + N > window_size or return_array:
return create_causal_mask(N, offset, window_size=window_size)
else:
return "causal"
else:
if window_size is None:
return None
# May need a mask for when window_size < max_size
if self.offset >= window_size and self.max_size > window_size:
idx = self._idx
if idx >= self.max_size:
idx = 0
if self.offset < self.max_size:
mask_size = self.offset + 1
else:
mask_size = self.max_size
mask = mx.arange(mask_size) >= (mask_size - window_size)
mask = mx.roll(mask, shift=idx + 1)
return mask
@classmethod
def merge(_, caches):
return BatchRotatingKVCache.merge(caches)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class ArraysCache(_BaseCache):
def __new__(cls, *args, **kwargs):
instance = super().__new__(cls)
instance.left_padding = None
instance.lengths = None
return instance
def __init__(self, size, left_padding: Optional[List[int]] = None):
self.cache = [None] * size
if left_padding:
self.left_padding = mx.array(left_padding)
@property
def batch_size(self):
for c in self.cache:
if c is not None:
return c.shape[0]
if self.left_padding is not None:
return self.left_padding.size
elif self.lengths is not None:
return self.lengths.size
else:
return 1
def __setitem__(self, idx, value):
self.cache[idx] = value
def __getitem__(self, idx):
return self.cache[idx]
@property
def state(self):
return self.cache
@state.setter
def state(self, v):
self.cache = v
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
self.cache = [c[batch_indices] if c is not None else None for c in self.cache]
if self.left_padding is not None:
self.left_padding = self.left_padding[batch_indices]
if self.lengths is not None:
self.lengths = self.lengths[batch_indices]
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
a_batch = self.batch_size
b_batch = other.batch_size
def cat(a, b):
shape = dtype = None
if a is not None:
shape = a.shape
dtype = a.dtype
if b is not None:
shape = b.shape
dtype = b.dtype
if shape is None:
return None
if a is None:
a = mx.zeros((a_batch,) + shape[1:], dtype=dtype)
if b is None:
b = mx.zeros((b_batch,) + shape[1:], dtype=dtype)
return mx.concatenate([a, b])
self.cache = [cat(c, o) for c, o in zip(self.cache, other.cache)]
self.left_padding = cat(self.left_padding, other.left_padding)
self.lengths = cat(self.lengths, other.lengths)
def extract(self, idx):
cache = ArraysCache(len(self.cache))
cache.cache = [c[idx : idx + 1] for c in self.cache]
return cache
def prepare(self, lengths=None, **kwargs):
self.lengths = mx.array(lengths)
def finalize(self):
self.lengths = None
self.left_padding = None
def advance(self, N):
if self.lengths is not None:
self.lengths -= N
if self.left_padding is not None:
self.left_padding -= N
def make_mask(self, N: int):
if self.left_padding is not None:
pos = mx.arange(N)
return pos >= self.left_padding[:, None]
elif self.lengths is not None:
pos = mx.arange(N)
return pos < self.lengths[:, None]
else:
return None
@classmethod
def merge(cls, caches):
n_state = len(caches[0].cache)
B = len(caches)
cache = cls(n_state)
# All caches are empty so return early
if all(c.empty() for c in caches):
cache.left_padding = mx.array([0] * B)
return cache
for e in range(n_state):
c_init = next(iter(c[e] for c in caches if c[e] is not None))
shape = list(c_init.shape)
shape[0] = B
cache[e] = mx.zeros(shape, c_init.dtype)
for i in range(B):
if caches[i][e] is None:
continue
cache[e][i : i + 1] = caches[i][e]
return cache
def empty(self):
return self.cache[0] is None
@property
def nbytes(self):
return sum(c.nbytes for c in self.cache if c is not None)
class ChunkedKVCache(_BaseCache):
step = 256
def __init__(self, chunk_size):
self.keys = None
self.values = None
self.offset = 0
self.chunk_size = chunk_size
self.start_position = 0
def maybe_trim_front(self):
# Maintain the cache below the chunk size
if self.keys is not None and self.keys.shape[2] >= self.chunk_size:
self.start_position += self.keys.shape[2] - self.chunk_size
self.keys = self.keys[..., -self.chunk_size :, :]
self.values = self.values[..., -self.chunk_size :, :]
def update_and_fetch(self, keys, values):
prev = self.offset - self.start_position
if self.keys is None or (prev + keys.shape[2]) > self.keys.shape[2]:
B, n_kv_heads, _, k_head_dim = keys.shape
v_head_dim = values.shape[3]
n_steps = (self.step + keys.shape[2] - 1) // self.step
k_shape = (B, n_kv_heads, n_steps * self.step, k_head_dim)
v_shape = (B, n_kv_heads, n_steps * self.step, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
if prev % self.step != 0:
self.keys = self.keys[..., :prev, :]
self.values = self.values[..., :prev, :]
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self.offset += keys.shape[2]
end = self.offset - self.start_position
self.keys[..., prev:end, :] = keys
self.values[..., prev:end, :] = values
return self.keys[..., :end, :], self.values[..., :end, :]
@property
def state(self):
if self.offset == self.keys.shape[2]:
return self.keys, self.values
else:
return (
self.keys[..., : self.offset, :],
self.values[..., : self.offset, :],
)
@state.setter
def state(self, v):
self.keys, self.values = v
self.offset = self.keys.shape[2]
def is_trimmable(self):
return True
def trim(self, n):
n = min(self.offset - self.start_position, n)
self.offset -= n
return n
@property
def meta_state(self):
return tuple(map(str, (self.chunk_size, self.start_position)))
@meta_state.setter
def meta_state(self, v):
self.chunk_size, self.start_position = map(int, v)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class CacheList(_BaseCache):
def __init__(self, *caches):
self.caches = caches
def __getitem__(self, idx):
return self.caches[idx]
def is_trimmable(self):
return all(c.is_trimmable() for c in self.caches)
def trim(self, n):
for c in self.caches:
m = c.trim(n)
return m
@property
def state(self):
return [c.state for c in self.caches]
@state.setter
def state(self, v):
for c, s in zip(self.caches, v):
c.state = s
@property
def meta_state(self):
return (
[type(c).__name__ for c in self.caches],
[c.meta_state for c in self.caches],
)
@meta_state.setter
def meta_state(self, v):
for c, m in zip(self.caches, v[1]):
c.meta_state = m
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
for c in self.caches:
c.filter(batch_indices)
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
for c, o in zip(self.caches, other.caches):
c.extend(o)
@classmethod
def merge(cls, caches):
cache = cls()
cache.caches = tuple(
caches[0].caches[i].merge([c.caches[i] for c in caches])
for i in range(len(caches[0].caches))
)
return cache
def extract(self, idx):
return CacheList(*(c.extract(idx) for c in self.caches))
def prepare(self, **kwargs):
for c in self.caches:
c.prepare(**kwargs)
def finalize(self):
for c in self.caches:
c.finalize()
def size(self):
return max(c.size() for c in self.caches)
def empty(self):
return self.caches[0].empty()
@property
def nbytes(self):
return sum(c.nbytes for c in self.caches)
@classmethod
def from_state(cls, state, meta_state):
obj = cls.__new__(cls)
obj.caches = [
globals()[c].from_state(s, m) for s, c, m in zip(state, *meta_state)
]
return obj
def dynamic_roll(x, shifts, axis):
n = x.shape[axis]
expand_shifts = (...,) + (None,) * (x.ndim - axis)
expand_indices = expand_shifts[:-1]
idx = (mx.arange(n)[expand_indices] - shifts[expand_shifts]) % n
rolled = mx.take_along_axis(x, idx, axis=axis)
return rolled
class BatchKVCache(_BaseCache):
step = 256
def __init__(self, left_padding: List[int]):
"""
The BatchKV cache expects inputs to be left-padded.
E.g. the following prompts:
[1, 3, 5]
[7]
[2, 6, 8, 9]
Should be padded like so:
[0, 1, 3, 5]
[0, 0, 0, 7]
[2, 6, 8, 9]
And ``left_padding`` specifies the amount of padding for each.
In this case, ``left_padding = [1, 3, 0]``.
"""
self.keys = None
self.values = None
self.left_padding = mx.array(left_padding)
self.offset = mx.array([-l for l in left_padding])
self._idx = 0
self._right_padding = None
def update_and_fetch(self, keys, values):
prev = self._idx
if self.keys is None or (prev + keys.shape[2]) > self.keys.shape[2]:
B, n_kv_heads, _, k_head_dim = keys.shape
v_head_dim = values.shape[3]
n_steps = (self.step + keys.shape[2] - 1) // self.step
k_shape = (B, n_kv_heads, n_steps * self.step, k_head_dim)
v_shape = (B, n_kv_heads, n_steps * self.step, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
if prev % self.step != 0:
self.keys = self.keys[..., :prev, :]
self.values = self.values[..., :prev, :]
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self.offset += keys.shape[2]
self._idx += keys.shape[2]
self.keys[..., prev : self._idx, :] = keys
self.values[..., prev : self._idx, :] = values
return self.keys[..., : self._idx, :], self.values[..., : self._idx, :]
def prepare(self, *, left_padding=None, lengths=None, right_padding=None):
if left_padding is not None:
if self.keys is not None:
raise ValueError(
"Left padding can only be added to an empty BatchKVCache"
)
left_padding = mx.array(left_padding)
self.left_padding += left_padding
self.offset -= left_padding
if right_padding is not None and max(right_padding) > 0:
self._right_padding = mx.array(right_padding)
def finalize(self):
if self._right_padding is not None:
padding = self._right_padding
self.keys = dynamic_roll(self.keys, padding[:, None], axis=2)
self.values = dynamic_roll(self.values, padding[:, None], axis=2)
self.offset -= padding
self.left_padding += padding
self._right_padding = None
@property
def state(self):
k, v = self.keys, self.values
if self._idx < k.shape[2]:
k = k[..., : self._idx, :]
v = v[..., : self._idx, :]
return k, v, self.offset, self.left_padding
@state.setter
def state(self, v):
self.keys, self.values, self.offset, self.left_padding = v
self._idx = self.keys.shape[2]
def is_trimmable(self):
return True
def trim(self, n):
n = min(self._idx, n)
self._idx -= n
self.offset -= n
return n
def make_mask(self, N: int, return_array: bool = False, **kwargs):
return create_causal_mask(
N, offset=self._idx, left_padding=self.left_padding, **kwargs
)
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
if self.keys is not None:
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
self.offset = self.offset[batch_indices]
self.left_padding = self.left_padding[batch_indices]
# Shift left to reduce padding
min_left_pad = self.left_padding.min().item()
if min_left_pad > 0:
if self.keys is not None:
self.keys = self.keys[..., min_left_pad:, :]
self.values = self.values[..., min_left_pad:, :]
self._idx -= min_left_pad
self.left_padding -= min_left_pad
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
if self.keys is None and other.keys is None:
self.left_padding = mx.concatenate([self.left_padding, other.left_padding])
self.offset = mx.concatenate([self.offset, other.offset])
return
max_idx = max(self._idx, other._idx)
L1 = L2 = 0
if self.keys is not None:
B, H, L1, D = self.keys.shape
M = self.values.shape[3]
if other.keys is not None:
B, H, L2, D = other.keys.shape
M = other.values.shape[3]
max_size = max(L1, L2)
# Pad the keys and values so they are right-justified
# with the index and the same size
def pad(c):
k, v = c.keys, c.values
if k is None:
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
left = max_idx - c._idx
right = max_size - k.shape[2] - left
if right < 0:
k = k[..., :right, :]
v = v[..., :right, :]
right = 0
if left != 0 or right != 0:
pad = [(0, 0), (0, 0), (left, right), (0, 0)]
k = mx.pad(k, pad)
v = mx.pad(v, pad)
left_padding = c.left_padding + left
return k, v, c.offset, left_padding
self.keys, self.values, self.offset, self.left_padding = map(
mx.concatenate, zip(*(pad(self), pad(other)))
)
self._idx = max_idx
def extract(self, idx):
cache = KVCache()
padding = self.left_padding[idx].item()
cache.keys = mx.contiguous(self.keys[idx : idx + 1, :, padding : self._idx])
cache.values = mx.contiguous(self.values[idx : idx + 1, :, padding : self._idx])
cache.offset = cache.keys.shape[2]
return cache
@classmethod
def merge(cls, caches):
lengths = [c.size() for c in caches]
max_length = max(lengths)
# No cache has content so make an empty one
if max_length == 0:
return BatchKVCache([0] * len(caches))
padding = [max_length - l for l in lengths]
B = len(caches)
H = max(c.keys.shape[1] for c in caches if c.keys is not None)
Dk = max(c.keys.shape[3] for c in caches if c.keys is not None)
Dv = max(c.values.shape[3] for c in caches if c.values is not None)
dt = next(iter(c.keys.dtype for c in caches if c.keys is not None))
keys = mx.zeros((B, H, max_length, Dk), dtype=dt)
values = mx.zeros((B, H, max_length, Dv), dtype=dt)
for i, (p, c) in enumerate(zip(padding, caches)):
if c.keys is None:
continue
keys[i : i + 1, :, p : p + c.offset] = c.keys[..., : c.offset, :]
values[i : i + 1, :, p : p + c.offset] = c.values[..., : c.offset, :]
cache = cls(padding)
cache.keys = keys
cache.values = values
cache.offset += keys.shape[2]
cache._idx = keys.shape[2]
return cache
def size(self):
return self._idx
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class BatchRotatingKVCache(_BaseCache):
step = 256
def __init__(self, max_size, left_padding: List[int]):
self.keys = None
self.values = None
self.left_padding = mx.array(left_padding)
self.offset = mx.array([-l for l in left_padding])
self.max_size = max_size
self._idx = 0
self._offset = 0
self.rotated = False
# Lengths for right_padded inputs to make sure that padding tokens do
# not evict valid tokens.
self._lengths = None
def _trim(self, trim_size, v, append=None):
if trim_size > 0:
v = v[..., trim_size:, :]
if append is not None:
return mx.concatenate([v, append], axis=2)
return v
def _temporal_order(self):
"""
Rearrange the cache into temporal order.
"""
if self.rotated:
self.keys = mx.roll(self.keys, -self._idx, axis=2)
self.values = mx.roll(self.values, -self._idx, axis=2)
self._idx = self.keys.shape[2]
self.rotated = False
def _update_concat(self, keys, values):
if self.keys is None:
self.keys = keys
self.values = values
else:
# Put the keys/values in temporal order to
# preserve context
self._temporal_order()
# Slice off the end if needed
if self.keys.shape[2] > self._idx:
self.keys = self.keys[..., : self._idx, :]
self.values = self.values[..., : self._idx, :]
# Roll right sequences that are padded to make sure that we don't
# trim valid cache entries
if self._lengths is not None:
roll = mx.maximum(0, self.offset - self._lengths)
self.keys = dynamic_roll(self.keys, roll[:, None], axis=2)
self.values = dynamic_roll(self.values, roll[:, None], axis=2)
self.left_padding += roll
self.offset -= roll
# The largest size is self.max_size + S - 1 to ensure
# every token gets at least self.max_size context
trim_size = self._idx - self.max_size + 1
if trim_size > 0:
self.left_padding -= trim_size
self.keys = self._trim(trim_size, self.keys, keys)
self.values = self._trim(trim_size, self.values, values)
self.offset += keys.shape[2]
self._offset += keys.shape[2]
self._idx = self.keys.shape[2]
# Make sure left_padding and offset are evaluated
self.keys = mx.depends(self.keys, (self.left_padding, self.offset))
return self.keys, self.values
def _update_in_place(self, keys, values):
if self._lengths is not None:
raise RuntimeError(
"finalize() should be called before deocoding with BatchRotatingKVCache"
)
# May not have hit the max size yet, so potentially
# keep growing the cache
B, n_kv_heads, S, k_head_dim = keys.shape
prev = self._offset
if self.keys is None or (
prev >= self.keys.shape[2] and self.keys.shape[2] < self.max_size
):
v_head_dim = values.shape[3]
new_size = min(self.step, self.max_size - prev)
k_shape = (B, n_kv_heads, new_size, k_head_dim)
v_shape = (B, n_kv_heads, new_size, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self._idx = prev
# Trim if needed
trim_size = self.keys.shape[2] - self.max_size
if trim_size > 0:
self.keys = self._trim(trim_size, self.keys)
self.values = self._trim(trim_size, self.values)
self._idx = self.max_size
self.left_padding -= trim_size
# Rotate
if self._idx == self.max_size:
self.rotated = True
self._idx = 0
if self.rotated:
self.left_padding -= S
# Assign
self.keys[..., self._idx : self._idx + S, :] = keys
self.values[..., self._idx : self._idx + S, :] = values
self._offset += S
self.offset += S
self._idx += S
# Make sure left_padding and offset are evaluated
self.keys = mx.depends(self.keys, (self.left_padding, self.offset))
# If the buffer is not full, slice off the end
if self._offset < self.max_size:
return (
self.keys[..., : self._offset, :],
self.values[..., : self._offset, :],
)
return self.keys, self.values
def update_and_fetch(self, keys, values):
if keys.shape[2] == 1:
return self._update_in_place(keys, values)
return self._update_concat(keys, values)
def prepare(self, *, left_padding=None, lengths=None, right_padding=None):
if left_padding is not None:
if self.keys is not None:
raise ValueError(
"Left padding can only be added to an empty BatchRotatingKVCache"
)
left_padding = mx.array(left_padding)
self.left_padding += left_padding
self.offset -= left_padding
if right_padding is not None and max(right_padding) > 0:
self._lengths = mx.array(lengths) + self.offset
def finalize(self):
if self._lengths is not None:
roll = mx.maximum(0, self.offset - self._lengths)
self.keys = dynamic_roll(self.keys, roll[:, None], axis=2)
self.values = dynamic_roll(self.values, roll[:, None], axis=2)
self.left_padding += roll
self.offset -= roll
self._lengths = None
@property
def state(self):
k, v = self.keys, self.values
if self._offset < k.shape[2]:
k, v = k[..., : self._offset, :], v[..., : self._offset, :]
return k, v, self.offset, self.left_padding
@state.setter
def state(self, v):
self.keys, self.values, self.offset, self.left_padding = v
@property
def meta_state(self):
return tuple(map(str, (self.max_size, self._offset, self._idx, self.rotated)))
@meta_state.setter
def meta_state(self, v):
self.max_size, self._offset, self._idx = map(
int,
v[:3],
)
self.rotated = bool(v[3])
def is_trimmable(self):
return self._offset < self.max_size
def trim(self, n):
n = min(self._offset, n)
self._offset -= n
self._idx -= n
self.offset -= n
return n
def to_quantized(self, group_size: int = 64, bits: int = 4) -> QuantizedKVCache:
raise NotImplementedError("BatchRotatingKVCache Quantization NYI")
def make_mask(
self, N: int, window_size: Optional[int] = None, return_array: bool = False
):
left_padding = self.left_padding
window_size = window_size or self.max_size
offset = min(self.max_size - 1, self._offset)
rinds = mx.arange(offset + N)
linds = mx.arange(offset, offset + N) if offset else rinds
linds = linds[:, None]
rinds = rinds[None]
mask = linds >= rinds
mask &= linds < rinds + window_size
if (trim_size := self._idx - self.max_size + int(N > 1)) > 0:
left_padding = left_padding - trim_size
rotated = N == 1 and (self.rotated or self._idx >= self.max_size)
if rotated:
left_padding = left_padding - 1
mask = mask & (rinds >= mx.expand_dims(left_padding, (1, 2, 3)))
if rotated:
idx = self._idx
if idx >= self.max_size:
idx = 0
mask = mx.roll(mask, shift=idx + 1, axis=-1)
return mask
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
if self.keys is not None:
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
self.offset = self.offset[batch_indices]
self.left_padding = self.left_padding[batch_indices]
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
if self.keys is None and other.keys is None:
self.left_padding = mx.concatenate([self.left_padding, other.left_padding])
self.offset = mx.concatenate([self.offset, other.offset])
return
if (self.rotated != other.rotated) or self._idx != other._idx:
self._temporal_order()
other._temporal_order()
max_idx = max(self._idx, other._idx)
L1 = L2 = 0
if self.keys is not None:
B, H, L1, D = self.keys.shape
M = self.values.shape[3]
if other.keys is not None:
B, H, L2, D = other.keys.shape
M = other.values.shape[3]
max_size = max(L1, L2)
def pad(c):
left = max_idx - c._idx
k, v = c.keys, c.values
if k is None:
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
right = max_size - k.shape[2] - left
if right < 0:
k = k[..., :right, :]
v = v[..., :right, :]
right = 0
if left != 0 or right != 0:
pad = [(0, 0), (0, 0), (left, right), (0, 0)]
k = mx.pad(k, pad)
v = mx.pad(v, pad)
left_padding = c.left_padding + left
return k, v, c.offset, left_padding
self.keys, self.values, self.offset, self.left_padding = map(
mx.concatenate, zip(*(pad(self), pad(other)))
)
self._idx = max_idx
self._offset = max(self._offset, other._offset)
def extract(self, idx):
mx.eval(self.left_padding, self.offset)
cache = RotatingKVCache(self.max_size)
padding = max(0, self.left_padding.tolist()[idx])
offset = self.offset.tolist()[idx]
cache.keys = self.keys[idx : idx + 1]
cache.values = self.values[idx : idx + 1]
cache._idx = self._idx
if self.rotated:
cache.keys = mx.roll(cache.keys, -self._idx, axis=2)
cache.values = mx.roll(cache.values, -self._idx, axis=2)
cache._idx = self.max_size
cache.keys = mx.contiguous(cache.keys[:, :, padding : cache._idx])
cache.values = mx.contiguous(cache.values[:, :, padding : cache._idx])
cache.offset = offset
cache._idx = cache.keys.shape[2]
return cache
@classmethod
def merge(cls, caches):
if not all(c.max_size == caches[0].max_size for c in caches):
raise ValueError(
"BatchRotatingKVCache can only merge caches with the same maximum size"
)
offsets = [c.offset for c in caches]
lengths = [c.size() for c in caches]
max_length = max(lengths)
# No cache has content so make an empty one
if max_length == 0:
return cls(caches[0].max_size, [0] * len(caches))
padding = [max_length - l for l in lengths]
B = len(caches)
H = max(c.keys.shape[1] for c in caches if c.keys is not None)
Dk = max(c.keys.shape[3] for c in caches if c.keys is not None)
Dv = max(c.values.shape[3] for c in caches if c.values is not None)
dt = next(iter(c.keys.dtype for c in caches if c.keys is not None))
keys = mx.zeros((B, H, max_length, Dk), dtype=dt)
values = mx.zeros((B, H, max_length, Dv), dtype=dt)
for i, (p, l, c) in enumerate(zip(padding, lengths, caches)):
if c.keys is None:
continue
keys[i : i + 1, :, p : p + l] = c._temporal_order(c.keys)[..., -l:, :]
values[i : i + 1, :, p : p + l] = c._temporal_order(c.values)[..., -l:, :]
cache = cls(caches[0].max_size, padding)
cache.keys = keys
cache.values = values
cache.offset = mx.array(offsets)
cache._idx = keys.shape[2]
cache._offset = keys.shape[2]
return cache
def size(self):
return min(self._offset, self.max_size)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class TokenBuffer:
"""A simple token buffer that can be efficiently appended to in a similar
fashion to the KVCache.
Perhaps these could share some logic in the future.
"""
step = 256
def __init__(self, tokens=[]):
self._buffer = mx.array(tokens, dtype=mx.int32)
self._size = len(tokens)
def update_and_fetch(self, tokens):
start = self._size
end = start + len(tokens)
new_size = ((end + self.step - 1) // self.step) * self.step
if new_size > self._buffer.size:
self._buffer = mx.concatenate(
[self._buffer, mx.zeros(new_size - self._buffer.size, dtype=mx.int32)]
)
self._buffer[start:end] = tokens
self._size = end
return self._buffer[:end]
@property
def state(self):
return self._buffer
@property
def tokens(self):
return self._buffer[: self._size]
@dataclass
class PromptTrieResult:
model: Any
exact: Optional[List[int]] # Exact match found
shorter: Optional[List[int]] # Longest prefix with a value
longer: Optional[List[int]] # Shortest value that extends beyond tokens
common_prefix: int # Length of common prefix with any path
class PromptTrie:
def __init__(self):
self._trie = {}
def add(self, model: Any, tokens: List[int], value: Any):
if model not in self._trie:
self._trie[model] = {}
current = self._trie[model]
for tok in tokens:
if tok not in current:
current[tok] = {}
current = current[tok]
prev = current.get("__value__", None)
current["__value__"] = value
return prev
def get(self, model: Any, tokens: List[int]):
current = self._trie[model]
for tok in tokens:
current = current[tok]
return current["__value__"]
def pop(self, model: Any, tokens: List[int]):
path = [self._trie[model]]
for tok in tokens:
path.append(path[-1][tok])
value = path[-1].pop("__value__")
for i in range(len(tokens), 0, -1):
node = path[i]
parent = path[i - 1]
tok = tokens[i - 1]
if len(node) > 0:
break
del parent[tok]
return value
def pop_prefixes(self, model: Any, tokens: List[int]):
values = []
current = self._trie[model]
for i, tok in enumerate(tokens):
if "__value__" in current:
values.append((i, current.pop("__value__")))
current = current[tok]
return values
def search(self, model: Any, tokens: List[int]) -> PromptTrieResult:
if model not in self._trie:
return PromptTrieResult(model, None, None, None, 0)
current = self._trie[model]
if not tokens and "__value__" in current:
return PromptTrieResult(model, [], None, None, 0)
# Walk the tokens as far as we can
last_index = -1
index = 0
while index < len(tokens) and tokens[index] in current:
current = current[tokens[index]]
if "__value__" in current:
last_index = index
index += 1
# Got an exact match
if last_index == len(tokens) - 1 >= 0:
return PromptTrieResult(model, tokens, None, None, 0)
# Check if we found a prefix at any point
shorter = None
if last_index > 0:
shorter = tokens[: last_index + 1]
# Check for sequences that are longer
longer = None
common_prefix = index
if index > 0:
best = None
stack = [(current, [])]
while stack:
current, extra = stack.pop()
if "__value__" in current:
if best is None or len(extra) < len(best):
best = extra
elif best is None or len(extra) < len(best):
for tok in current:
stack.append((current[tok], extra + [tok]))
longer = tokens[:index] + best
return PromptTrieResult(model, None, shorter, longer, common_prefix)
class LRUPromptCache:
@dataclass
class CacheEntry:
prompt_cache: List[Any]
nbytes: int
cache_type: str
class CacheOrder:
def __init__(self, ordering: List[str] = ["assistant", "user", "system"]):
self._ordering = ordering
self._lrus = {k: deque() for k in ordering}
def __len__(self):
return sum(len(lru) for lru in self._lrus.values())
def push(self, model: Any, tokens: List[Any], cache_type: str = "assistant"):
self._lrus[cache_type].append((model, tokens))
def remove(self, model: Any, tokens: List[Any]):
for cache_type in self._ordering:
try:
self._lrus[cache_type].remove((model, tokens))
break
except ValueError:
pass
def pop(self):
i = 0
while i + 1 < len(self._ordering):
lru_a = self._lrus[self._ordering[i]]
lru_b = self._lrus[self._ordering[i + 1]]
if lru_a and len(lru_a) >= len(lru_b):
return lru_a.popleft()
i += 1
return lru_b.popleft()
def __init__(self, max_size: int = 10, max_bytes: int = 1 << 63):
self.max_size = max_size
self.max_bytes = max_bytes
self._trie = PromptTrie()
self._lru = LRUPromptCache.CacheOrder()
self._n_bytes = 0
self._n_bytes_by_type = {k: 0 for k in self._lru._ordering}
def __len__(self):
return len(self._lru)
@property
def nbytes(self):
return self._n_bytes
def fetch_nearest_cache(self, model: Any, tokens: List[int]):
result = self._trie.search(model, tokens)
if result.exact is not None:
cache_entry = self._trie.get(result.model, result.exact)
return copy.deepcopy(cache_entry.prompt_cache), []
short_length = len(result.shorter) if result.shorter is not None else 0
if result.longer is not None and result.common_prefix > short_length:
cache_entry = self._trie.get(result.model, result.longer)
if can_trim_prompt_cache(cache_entry.prompt_cache):
cache = copy.deepcopy(cache_entry.prompt_cache)
prefix = min(len(tokens) - 1, result.common_prefix)
num_to_trim = len(result.longer) - prefix
trim_prompt_cache(cache, num_to_trim)
return cache, tokens[prefix:]
if short_length > 0:
cache_entry = self._trie.get(result.model, result.shorter)
return copy.deepcopy(cache_entry.prompt_cache), tokens[short_length:]
return None, tokens
def insert_cache(
self,
model: Any,
tokens: List[int],
prompt_cache: List[Any],
*,
cache_type: str = "assistant",
):
# Make the cache entry
entry = LRUPromptCache.CacheEntry(
prompt_cache, sum(c.nbytes for c in prompt_cache), cache_type
)
# Insert into the trie and update the byte counter and lru position
self._n_bytes += entry.nbytes
self._n_bytes_by_type[cache_type] += entry.nbytes
prev = self._trie.add(model, tokens, entry)
if prev is not None:
self._n_bytes -= prev.nbytes
self._n_bytes_by_type[prev.cache_type] -= prev.nbytes
self._lru.remove(model, tokens)
self._lru.push(model, tokens, cache_type)
# If it is a trimmable cache remove all prefixes cause they just take
# space
if can_trim_prompt_cache(prompt_cache):
for prefix_len, entry in self._trie.pop_prefixes(model, tokens):
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
self._lru.remove(model, tokens[:prefix_len])
# Ensure we match the constraints
if len(self._lru) > self.max_size:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
while self._n_bytes > self.max_bytes:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
def trim_to(
self, *, n_sequences: Optional[int] = None, n_bytes: Optional[int] = None
):
n_sequences = max(0, n_sequences) if n_sequences is not None else 1 << 63
n_bytes = max(0, n_bytes) if n_bytes is not None else 1 << 63
while len(self._lru) > n_sequences:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
while self._n_bytes > n_bytes:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
def stats_by_type(self):
result = {}
for cache_type in self._lru._ordering:
result[cache_type] = {
"n_sequences": len(self._lru._lrus[cache_type]),
"n_bytes": self._n_bytes_by_type[cache_type],
}
return result