Skip to content

Commit 5fe06e0

Browse files
authored
Refactor cache helpers in MLX (pytorch#22912)
As titled
1 parent fa69948 commit 5fe06e0

2 files changed

Lines changed: 270 additions & 154 deletions

File tree

‎backends/mlx/llm/cache.py‎

Lines changed: 190 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"""
1414

1515
import inspect
16-
from typing import Tuple
16+
from typing import Sequence, Tuple, TypedDict
1717

1818
import torch
1919
import torch.nn as nn
@@ -22,11 +22,12 @@
2222
from executorch.backends.mlx import custom_ops as _mlx_custom_ops # noqa: F401
2323

2424

25-
def resolve_hf_text_config(config):
26-
"""Return the text config for multimodal HF models, or the config itself."""
27-
if hasattr(config, "get_text_config"):
28-
return config.get_text_config()
29-
return getattr(config, "text_config", config)
25+
class KVCacheLayerConfig(TypedDict):
26+
"""Resolved geometry for one cache owner; zero window means full attention."""
27+
28+
num_kv_heads: int
29+
head_dim: int
30+
window_size: int
3031

3132

3233
def resolve_hf_cache_layout(config):
@@ -38,7 +39,7 @@ def resolve_hf_cache_layout(config):
3839
so our replacement cache allocates the same number of layers with the same
3940
`(num_heads, head_dim)` for each backing cache entry.
4041
"""
41-
text_config = resolve_hf_text_config(config)
42+
text_config = config.get_text_config()
4243
layer_types = getattr(text_config, "layer_types", None)
4344

4445
if layer_types is None:
@@ -225,6 +226,51 @@ def update(
225226
return self.k_cache[:, :, :, :], self.v_cache[:, :, :, :]
226227

227228

229+
def sliding_window_mask(
230+
start_pos: int,
231+
seq_len: int,
232+
window_size: int,
233+
buffer_size: int,
234+
dtype: torch.dtype,
235+
) -> torch.Tensor:
236+
"""Build a ring-buffer sliding-window attention mask from static geometry.
237+
238+
Branchless and derived purely from ``(start_pos, seq_len, window_size,
239+
buffer_size)`` - no mutable state and no live cache reference - so an export
240+
attention can build it from the traced key length instead of the cache object.
241+
242+
Returns an additive mask ``[1, 1, seq_len, buffer_size]`` (0 = attend,
243+
-inf = block) in ``dtype``.
244+
"""
245+
w = window_size
246+
b = buffer_size
247+
end_pos = start_pos + seq_len
248+
249+
# Slot indices [buffer_size]
250+
slots = torch.arange(b, dtype=torch.long)
251+
252+
last_write_slot = (end_pos - 1) % b
253+
current_cycle_base = end_pos - 1 - last_write_slot
254+
pos_current = current_cycle_base + slots
255+
pos_previous = current_cycle_base - b + slots
256+
257+
cache_pos = torch.where(slots <= last_write_slot, pos_current, pos_previous)
258+
259+
# Query positions [seq_len, 1]
260+
pos_q = (start_pos + torch.arange(seq_len, dtype=torch.long)).view(-1, 1)
261+
262+
# Delta from query to each cached position [seq_len, buffer_size]
263+
delta = pos_q - cache_pos
264+
265+
# A slot is attendable if: filled (pos >= 0), causal (delta >= 0),
266+
# and within the sliding window (delta < w)
267+
attn_mask = (cache_pos >= 0) & (delta >= 0) & (delta < w)
268+
269+
zero = torch.zeros(1, dtype=dtype)
270+
neg_inf = torch.full((1,), float("-inf"), dtype=dtype)
271+
return torch.where(attn_mask, zero, neg_inf).unsqueeze(0).unsqueeze(0)
272+
273+
228274
class RingBufferKVCache(nn.Module):
229275
"""
230276
Ring buffer KV cache for sliding window attention.
@@ -348,38 +394,22 @@ def create_sliding_window_mask(self, start_pos: int, seq_len: int) -> torch.Tens
348394
Additive mask [1, 1, seq_len, buffer_size] in the cache's dtype,
349395
where 0 = attend, -inf = block.
350396
"""
351-
w = self.window_size
352-
b = self.buffer_size
353-
end_pos = start_pos + seq_len
354-
355-
# Slot indices [buffer_size]
356-
slots = torch.arange(b, dtype=torch.long)
357-
358-
last_write_slot = (end_pos - 1) % b
359-
current_cycle_base = end_pos - 1 - last_write_slot
360-
pos_current = current_cycle_base + slots
361-
pos_previous = current_cycle_base - b + slots
362-
363-
cache_pos = torch.where(slots <= last_write_slot, pos_current, pos_previous)
364-
365-
# Query positions [seq_len, 1]
366-
pos_q = (start_pos + torch.arange(seq_len, dtype=torch.long)).view(-1, 1)
367-
368-
# Delta from query to each cached position [seq_len, buffer_size]
369-
delta = pos_q - cache_pos
370-
371-
# A slot is attendable if: filled (pos >= 0), causal (delta >= 0),
372-
# and within the sliding window (delta < w)
373-
attn_mask = (cache_pos >= 0) & (delta >= 0) & (delta < w)
374-
375397
# Use cache dtype (e.g. bf16) to avoid float32 AsTypeNode casts in SDPA
376-
dtype = self.k_cache.dtype
377-
zero = torch.zeros(1, dtype=dtype)
378-
neg_inf = torch.full((1,), float("-inf"), dtype=dtype)
379-
return torch.where(attn_mask, zero, neg_inf).unsqueeze(0).unsqueeze(0)
398+
return sliding_window_mask(
399+
start_pos=start_pos,
400+
seq_len=seq_len,
401+
window_size=self.window_size,
402+
buffer_size=self.buffer_size,
403+
dtype=self.k_cache.dtype,
404+
)
380405

381406

382-
from transformers.cache_utils import StaticCache
407+
from transformers.cache_utils import (
408+
Cache,
409+
StaticCache,
410+
StaticLayer,
411+
StaticSlidingWindowLayer,
412+
)
383413

384414

385415
class HFStaticCache(StaticCache):
@@ -404,6 +434,115 @@ class HFStaticCache(StaticCache):
404434
... cache_kwargs={"cache_position": pos_tensor})
405435
"""
406436

437+
@classmethod
438+
def from_layer_configs( # noqa: C901
439+
cls,
440+
layer_configs: Sequence[KVCacheLayerConfig],
441+
*,
442+
max_cache_len: int,
443+
max_write_len: int,
444+
max_batch_size: int = 1,
445+
dtype: torch.dtype = torch.float32,
446+
) -> "HFStaticCache":
447+
"""Allocate only the final MLX buffers, without reading a model config."""
448+
for name, value in (
449+
("max_batch_size", max_batch_size),
450+
("max_cache_len", max_cache_len),
451+
("max_write_len", max_write_len),
452+
):
453+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
454+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
455+
if max_batch_size != 1:
456+
raise ValueError("Only max_batch_size=1 is supported")
457+
if max_write_len > max_cache_len:
458+
raise ValueError("max_write_len must be <= max_cache_len")
459+
if not layer_configs:
460+
raise ValueError("layer_configs must contain at least one cache owner")
461+
for i, config in enumerate(layer_configs):
462+
if not isinstance(config, dict):
463+
raise ValueError(f"layer_configs[{i}] must be a dictionary")
464+
for name in ("num_kv_heads", "head_dim", "window_size"):
465+
value = config.get(name)
466+
minimum = 0 if name == "window_size" else 1
467+
if (
468+
isinstance(value, bool)
469+
or not isinstance(value, int)
470+
or value < minimum
471+
):
472+
raise ValueError(
473+
f"layer_configs[{i}].{name} must be an integer >= "
474+
f"{minimum}, got {value!r}"
475+
)
476+
window_size = config["window_size"]
477+
if window_size > 0 and max_write_len > window_size:
478+
raise ValueError(
479+
f"max_write_len must be <= layer_configs[{i}].window_size"
480+
)
481+
482+
# Native layers supply HF bookkeeping only; never run their allocating
483+
# lazy_initialization or the model-config-based StaticCache constructor.
484+
layers = [
485+
(
486+
StaticSlidingWindowLayer(
487+
max_cache_len=config["window_size"],
488+
sliding_window=config["window_size"],
489+
)
490+
if config["window_size"] > 0
491+
else StaticLayer(max_cache_len=max_cache_len)
492+
)
493+
for config in layer_configs
494+
]
495+
cache = cls.__new__(cls)
496+
Cache.__init__(cache, layers=layers)
497+
cache.num_model_layers = len(layer_configs)
498+
cache.num_layers = len(layer_configs)
499+
cache.layer_types = [
500+
"sliding_attention" if config["window_size"] else "full_attention"
501+
for config in layer_configs
502+
]
503+
cache.num_heads = [config["num_kv_heads"] for config in layer_configs]
504+
cache.head_dim = [config["head_dim"] for config in layer_configs]
505+
cache.kv_cache = nn.ModuleList()
506+
for config, layer in zip(layer_configs, layers):
507+
if config["window_size"] > 0:
508+
layer_cache = RingBufferKVCache(
509+
max_batch_size=max_batch_size,
510+
max_context_length=config["window_size"],
511+
n_heads=config["num_kv_heads"],
512+
head_dim=config["head_dim"],
513+
dtype=dtype,
514+
max_write_len=max_write_len,
515+
)
516+
else:
517+
layer_cache = KVCache(
518+
max_batch_size=max_batch_size,
519+
max_context_length=max_cache_len,
520+
n_heads=config["num_kv_heads"],
521+
head_dim=config["head_dim"],
522+
enable_dynamic_shape=True,
523+
dtype=dtype,
524+
)
525+
cache.kv_cache.append(layer_cache)
526+
layer.keys = layer_cache.k_cache
527+
layer.values = layer_cache.v_cache
528+
layer.dtype = dtype
529+
layer.device = layer.keys.device
530+
layer.batch_size = max_batch_size
531+
layer.max_batch_size = max_batch_size
532+
layer.num_heads = config["num_kv_heads"]
533+
layer.head_dim = config["head_dim"]
534+
layer.k_head_dim = config["head_dim"]
535+
layer.v_head_dim = config["head_dim"]
536+
# HF versions use either scalar or length-one counters; preserve
537+
# the native shape for the export wrapper's in-place position copy.
538+
layer.cumulative_length = torch.as_tensor(
539+
getattr(layer, "cumulative_length", 0),
540+
dtype=torch.long,
541+
device=layer.device,
542+
)
543+
layer.is_initialized = True
544+
return cache
545+
407546
def __init__(
408547
self,
409548
config,
@@ -425,7 +564,7 @@ def __init__(
425564
"""
426565
# Resolve dimensions from the text config before calling parent. Multimodal
427566
# configs like Gemma 4 expose transformer dims under text_config.
428-
text_config = resolve_hf_text_config(config)
567+
text_config = config.get_text_config()
429568
layer_types, num_heads, head_dims = resolve_hf_cache_layout(config)
430569
num_model_layers = text_config.num_hidden_layers
431570
actual_max_cache_len = max_cache_len or getattr(
@@ -538,8 +677,8 @@ def update(
538677
# Current HF ExecuTorch wrappers copy the requested cache position
539678
# into each StaticCache layer's cumulative_length before forward().
540679
if hasattr(self.layers[layer_idx], "cumulative_length"):
541-
# cumulative_length is a scalar; KVCache.update indexes [0], so
542-
# give it the 1-D shape a cache_kwargs caller would have passed.
680+
# Normalize scalar and length-one counters to the 1-D shape
681+
# expected by KVCache.update, which indexes [0].
543682
cache_position = self.layers[layer_idx].cumulative_length.reshape(1)
544683
else:
545684
raise RuntimeError(
@@ -562,8 +701,19 @@ def get_seq_length(self, layer_idx: int = 0) -> int:
562701
# Check if any value in the head_dim is non-zero for each position
563702
return (k_cache[0, 0, :, 0] != 0).sum().item()
564703

704+
def get_mask_sizes(
705+
self, query_length=None, layer_idx: int = 0, *, cache_position=None
706+
) -> Tuple[int, int]:
707+
"""Return physical buffer geometry without native sliding-cache branches.
708+
709+
HF passes either a query length or (in older versions) cache positions.
710+
Neither affects the full buffers returned by MLX updates. Ring slots are
711+
not a contiguous token range; MLX attention builds their mask separately.
712+
"""
713+
return self.get_max_cache_shape(layer_idx), 0
714+
565715
def get_max_cache_shape(self, layer_idx: int = 0) -> int:
566-
return self.max_cache_len
716+
return self.kv_cache[layer_idx].k_cache.shape[2]
567717

568718
def reset(self):
569719
for layer_cache in self.kv_cache:

0 commit comments

Comments
 (0)