1313"""
1414
1515import inspect
16- from typing import Tuple
16+ from typing import Sequence , Tuple , TypedDict
1717
1818import torch
1919import torch .nn as nn
2222from 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
3233def 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+
228274class 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
385415class 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