Skip to content

Commit ed07a9d

Browse files
[MLX] Fix HF sliding-window KV cache sizing and enable ring buffers (#21635)
**Summary** export_llm_hf sized every layer's KV cache to the model's sliding_window, which truncates the full-attention layers of a hybrid model. Sliding attention was also never actually enabled on this path: replace_hf_cache_with_mlx gave all layers plain linear caches and plain causal SDPA. Hybrid models now install ring buffers for sliding layers and full-length linear caches for full-attention layers. replace_hf_cache_with_mlx_ring_buffer takes max_cache_len separately from window_size, because it previously applied one size to both. The dynamic sequence dimension is bounded by the window rather than the cache length, since a ring cannot absorb a single step longer than its window. **Files** * source_transformation.py — replace_hf_cache_with_mlx_ring_buffer gains a max_cache_len parameter so full-attention layers are sized to the whole context while sliding layers stay bounded by the window, instead of applying window_size to both. * export_llm_hf.py — stops capping the cache length to the window, routes models with a sliding_window to the ring-buffer cache and sliding-window SDPA, and bounds the dynamic sequence dimension by the window rather than the cache length. **Test** Exported: python -m executorch.backends.mlx.examples.llm.export_llm_hf \ --model-id unsloth/gemma-3-1b-it --output gemma3_1b.pte \ --use-custom-sdpa --use-custom-kv-cache --max-seq-len 1024 --dtype fp32 Ran the .pte step by step through pybindings and compared each step's top-1 token against HuggingFace eager, teacher-forced on the same token sequence, over a full 1024-token context split at the sliding-window boundary. Exported and run in fp32, the two agree on every step. Repeating in bf16 gives a small number of disagreements on both sides of the boundary (2.8% below, 1.4% above).
1 parent d863a27 commit ed07a9d

2 files changed

Lines changed: 69 additions & 26 deletions

File tree

backends/mlx/examples/llm/export_llm_hf.py

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -184,13 +184,15 @@ def _export_with_custom_components(
184184
# Gemma 4 keep transformer attributes under text_config.
185185
text_config = model.config.get_text_config()
186186
sliding_window = getattr(text_config, "sliding_window", None)
187+
# Full-attention layers retain all history, so they cannot be bounded by the
188+
# sliding window. Sizing every layer to the window truncates them and the
189+
# model loses its context past `sliding_window` tokens.
190+
effective_cache_len = max_seq_len
187191
if sliding_window is not None:
188-
logger.info(f"Model has sliding_window={sliding_window}")
189-
# Cap max_seq_len to sliding window size for cache allocation
190-
effective_cache_len = min(max_seq_len, sliding_window)
191-
logger.info(f" Capping cache length to sliding window: {effective_cache_len}")
192-
else:
193-
effective_cache_len = max_seq_len
192+
logger.info(
193+
f"Model has sliding_window={sliding_window}; "
194+
f"cache length {effective_cache_len}"
195+
)
194196

195197
# The HF ExecuTorch cache wrappers validate both generation_config.use_cache
196198
# and the text config's use_cache flag before constructing static caches.
@@ -228,26 +230,52 @@ def _export_with_custom_components(
228230
)
229231

230232
if use_custom_kv_cache:
231-
from executorch.backends.mlx.llm.source_transformation import (
232-
replace_hf_cache_with_mlx,
233-
)
234-
235233
if sliding_window is not None:
234+
from executorch.backends.mlx.llm.source_transformation import (
235+
replace_hf_cache_with_mlx_ring_buffer,
236+
)
237+
236238
logger.info(
237-
"Replacing HuggingFace StaticCache with HFStaticCache "
238-
f"(capped to sliding window: {effective_cache_len})..."
239+
"Replacing HuggingFace HybridCache with MLX ring buffers "
240+
f"(window {sliding_window}, cache length {effective_cache_len})..."
241+
)
242+
replace_hf_cache_with_mlx_ring_buffer(
243+
exportable,
244+
model.config,
245+
max_batch_size=1,
246+
window_size=sliding_window,
247+
max_cache_len=effective_cache_len,
248+
dtype=torch_dtype,
239249
)
240250
else:
241-
logger.info("Replacing HuggingFace StaticCache with HFStaticCache...")
251+
from executorch.backends.mlx.llm.source_transformation import (
252+
replace_hf_cache_with_mlx,
253+
)
242254

243-
replace_hf_cache_with_mlx(
244-
exportable,
245-
model.config,
246-
max_batch_size=1,
247-
max_cache_len=effective_cache_len,
248-
dtype=torch_dtype,
249-
)
250-
logger.info(" HFStaticCache installed successfully")
255+
logger.info(
256+
"Replacing HuggingFace StaticCache with HFStaticCache "
257+
f"(cache length {effective_cache_len})..."
258+
)
259+
replace_hf_cache_with_mlx(
260+
exportable,
261+
model.config,
262+
max_batch_size=1,
263+
max_cache_len=effective_cache_len,
264+
dtype=torch_dtype,
265+
)
266+
logger.info(" MLX cache installed successfully")
267+
268+
if use_custom_sdpa and sliding_window is not None:
269+
from executorch.backends.mlx.llm.hf_attention import (
270+
register_mlx_sliding_window_attention,
271+
)
272+
273+
# Registered after the wrapper exists: the SDPA closure captures it
274+
# to reach the ring buffers when building masks.
275+
register_mlx_sliding_window_attention(exportable)
276+
model.config._attn_implementation = "mlx_sliding_window"
277+
text_config._attn_implementation = "mlx_sliding_window"
278+
logger.info("Registered MLX sliding-window SDPA")
251279

252280
from executorch.backends.mlx.llm.quantization import quantize_model_
253281

@@ -266,7 +294,13 @@ def _export_with_custom_components(
266294
example_input_ids = torch.zeros((1, seq_length), dtype=torch.long)
267295
example_cache_position = torch.arange(seq_length, dtype=torch.long)
268296

269-
seq_len_dim = torch.export.Dim("seq_length_dim", max=effective_cache_len - 1)
297+
# A sliding layer holds `sliding_window` cells, so it cannot absorb a single
298+
# step longer than that -- a separate limit from how much history the caches
299+
# retain.
300+
max_step_len = (
301+
min(max_seq_len, sliding_window) if sliding_window is not None else max_seq_len
302+
)
303+
seq_len_dim = torch.export.Dim("seq_length_dim", max=max_step_len - 1)
270304
dynamic_shapes = {
271305
"input_ids": {1: seq_len_dim},
272306
"cache_position": {0: seq_len_dim},

backends/mlx/llm/source_transformation.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def replace_hf_cache_with_mlx_ring_buffer(
163163
config,
164164
max_batch_size: int = 1,
165165
window_size: int = 512,
166+
max_cache_len: int | None = None,
166167
dtype: torch.dtype = torch.float32,
167168
) -> nn.Module:
168169
"""
@@ -176,19 +177,26 @@ def replace_hf_cache_with_mlx_ring_buffer(
176177
module: HF exportable module with static_cache or cache attribute
177178
config: HF model config
178179
max_batch_size: Maximum batch size (default: 1)
179-
window_size: Sliding window size (cache capacity per layer)
180+
window_size: Sliding window size (capacity of the sliding-layer rings)
181+
max_cache_len: Capacity of the full-attention layers; defaults to
182+
``window_size``, which is only correct for models with no
183+
full-attention layers
180184
dtype: Cache tensor dtype
181185
182186
Raises:
183187
ValueError: If module has no recognized cache attribute
184188
"""
185189
from transformers.cache_utils import StaticCache
186190

191+
# Full-attention layers retain every position, so they are sized to the whole
192+
# context; only the sliding layers are bounded by the window.
193+
full_cache_len = max_cache_len if max_cache_len is not None else window_size
194+
187195
# Create HFStaticCache with ring buffer layers
188196
mlx_cache = HFStaticCache(
189197
config=config,
190198
max_batch_size=max_batch_size,
191-
max_cache_len=window_size,
199+
max_cache_len=full_cache_len,
192200
dtype=dtype,
193201
)
194202

@@ -242,8 +250,9 @@ def _install_cache(attr_name):
242250
raise ValueError("Module must have 'static_cache' or 'cache' attribute")
243251

244252
logger.info(
245-
f"Installed hybrid MLX cache: {num_ring_layers} ring-buffer layers / "
246-
f"{num_cache_layers} total cache layers, window_size={window_size}"
253+
f"Installed hybrid MLX cache: {num_ring_layers} ring-buffer layers "
254+
f"(window_size={window_size}) / {num_cache_layers} total cache layers "
255+
f"(full-attention length {full_cache_len})"
247256
)
248257

249258
return module

0 commit comments

Comments
 (0)