Skip to content

Commit 66feb0a

Browse files
[MLX] Reduce physical footprint memory in RingBufferKVCache for chunked prefill (pytorch#20341)
When doing chunked prefill, the RingBufferKVCache does not need 2x window size, but instead window_size + max_write_length - 1 (prefill chunk size). This PR exposes that knob and wires it to gemma4 31b MLX export, which uses chunk_size 256, smaller than gemma4's window size (1024). Reduces phys_footprint on a 4K export by around −0.68 GiB (from 13.84 GiB to 13.16 GiB). --------- Co-authored-by: uddeshsingh <uddeshsingh@gmail.com>
1 parent d73b4e7 commit 66feb0a

4 files changed

Lines changed: 66 additions & 9 deletions

File tree

backends/mlx/llm/cache.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,12 @@ class RingBufferKVCache(nn.Module):
228228
the attention function creates the mask lazily by accessing the cache
229229
via a closure. This avoids tracing issues with torch.export.
230230
231-
Layout: BHSD [batch_size, num_heads, window_size, head_dim]
231+
Layout: BHSD [batch_size, num_heads, window_size + max_write_len - 1, head_dim]
232+
233+
``max_write_len`` bounds the largest single ``update`` write and sets the
234+
buffer slack beyond the window (buffer_size = window_size + max_write_len -
235+
1). It defaults to the full context length, i.e. ``2 * window_size - 1``; pass the chunk size when doing
236+
chunked prefill to shrink it further.
232237
233238
Example:
234239
>>> cache = RingBufferKVCache(
@@ -250,6 +255,7 @@ def __init__(
250255
n_heads: int,
251256
head_dim: int,
252257
dtype: torch.dtype = torch.float32,
258+
max_write_len: int | None = None,
253259
):
254260
super().__init__()
255261
assert (
@@ -258,13 +264,23 @@ def __init__(
258264
self.max_batch_size = max_batch_size
259265
self.max_context_length = max_context_length
260266
self.window_size = max_context_length
261-
self.buffer_size = 2 * max_context_length
267+
# Slack beyond the window so a multi-token write never overwrites data
268+
# that earlier queries in the same batch still need. After a write of
269+
# seq_len tokens, the in-window positions that must coexist span a
270+
# contiguous range of seq_len + window_size - 1 positions, so the ring
271+
# needs buffer_size >= window_size + max_write_len - 1. Defaults to the
272+
# full window; with chunked prefill, pass the chunk size to shrink it.
273+
self.max_write_len = (
274+
max_write_len if max_write_len is not None else max_context_length
275+
)
276+
assert (
277+
self.max_write_len <= self.window_size
278+
), f"max_write_len={self.max_write_len} must be <= window_size={self.window_size}"
279+
self.buffer_size = self.window_size + self.max_write_len - 1
262280
self.n_heads = n_heads
263281
self.head_dim = head_dim
264282

265-
# Cache buffers [B, H, 2*window_size, D]
266-
# 2× buffer ensures multi-token writes never overwrite data that
267-
# earlier queries in the same batch still need (matches ET behavior).
283+
# Cache buffers [B, H, window_size + max_write_len - 1, D]
268284
cache_shape = (max_batch_size, n_heads, self.buffer_size, head_dim)
269285
self.register_buffer(
270286
"k_cache", torch.zeros(cache_shape, dtype=dtype, device="cpu")
@@ -292,7 +308,7 @@ def update(
292308
seq_len = k_val.size(2)
293309
torch._check(seq_len == v_val.size(2))
294310
torch._check(start_pos >= 0)
295-
torch._check(seq_len <= self.window_size)
311+
torch._check(seq_len <= self.max_write_len)
296312
else:
297313
start_pos = input_pos
298314

backends/mlx/test/test_ops.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2704,6 +2704,7 @@ def __init__(
27042704
max_context_length: int,
27052705
n_heads: int,
27062706
head_dim: int,
2707+
max_write_len: Optional[int] = None,
27072708
):
27082709
super().__init__()
27092710
from executorch.backends.mlx.llm.cache import RingBufferKVCache
@@ -2713,6 +2714,7 @@ def __init__(
27132714
max_context_length=max_context_length,
27142715
n_heads=n_heads,
27152716
head_dim=head_dim,
2717+
max_write_len=max_write_len,
27162718
)
27172719

27182720
def forward(
@@ -2749,18 +2751,31 @@ def __init__(
27492751
head_dim: int = 64,
27502752
max_context_length: int = 64,
27512753
seq_step: int = 4,
2754+
max_write_len: Optional[int] = None,
27522755
):
27532756
self.max_batch_size = max_batch_size
27542757
self.n_heads = n_heads
27552758
self.head_dim = head_dim
27562759
self.max_context_length = max_context_length
27572760
self.seq_step = seq_step
2761+
self.max_write_len = max_write_len
27582762

27592763
@classmethod
27602764
def get_test_configs(cls) -> List["RingBufferKVCacheTest"]:
27612765
return [
27622766
cls(),
27632767
cls(n_heads=8, head_dim=32, max_context_length=32, seq_step=2),
2768+
# Reduced buffer (max_write_len < window) with a wrapping write:
2769+
# window=8, max_write_len=4 -> buffer_size=11. The test write of 4
2770+
# tokens at start_pos=8 wraps the ring (slots 8,9,10 then 0),
2771+
# exercising the tighter buffer
2772+
cls(
2773+
n_heads=8,
2774+
head_dim=32,
2775+
max_context_length=8,
2776+
seq_step=2,
2777+
max_write_len=4,
2778+
),
27642779
]
27652780

27662781
def create_model(self) -> nn.Module:
@@ -2769,6 +2784,7 @@ def create_model(self) -> nn.Module:
27692784
max_context_length=self.max_context_length,
27702785
n_heads=self.n_heads,
27712786
head_dim=self.head_dim,
2787+
max_write_len=self.max_write_len,
27722788
)
27732789

27742790
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
@@ -2793,7 +2809,13 @@ def create_test_inputs(self) -> Tuple[torch.Tensor, ...]:
27932809
return (input_pos, k_val, v_val)
27942810

27952811
def get_dynamic_shapes(self) -> Optional[Dict[str, any]]:
2796-
seq_dim = Dim("seq_step", min=1, max=self.max_context_length)
2812+
# A single write can't exceed max_write_len (defaults to the window).
2813+
seq_max = (
2814+
self.max_write_len
2815+
if self.max_write_len is not None
2816+
else self.max_context_length
2817+
)
2818+
seq_dim = Dim("seq_step", min=1, max=seq_max)
27972819
return {
27982820
"input_pos": None,
27992821
"k_val": {2: seq_dim},

examples/models/gemma4_31b/export.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,13 +328,17 @@ def _export_mlx(
328328
from executorch.exir.passes import MemoryPlanningPass
329329
from torch.export import Dim, export
330330

331+
max_prefill = 256
332+
331333
mlx_source_transformations(
332-
model, dtype=torch.bfloat16, use_turboquant=use_turboquant
334+
model,
335+
dtype=torch.bfloat16,
336+
use_turboquant=use_turboquant,
337+
max_write_len=max_prefill,
333338
)
334339

335340
materialize_runtime_buffers(model, dtype=torch.bfloat16)
336341

337-
max_prefill = 256
338342
seq_dim = Dim("seq_len", min=1, max=max_prefill)
339343

340344
print(f"Exporting (T in [1, {max_prefill}])...")

examples/models/gemma4_31b/mlx_source_transformations.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def mlx_source_transformations(
158158
model: nn.Module,
159159
dtype: torch.dtype = torch.bfloat16,
160160
use_turboquant: bool = False,
161+
max_write_len: int | None = None,
161162
) -> None:
162163
"""Apply MLX source transformations to a Gemma 4 31B model in-place.
163164
@@ -177,19 +178,33 @@ def mlx_source_transformations(
177178
use_turboquant: If True, swap full-attention layers' KV caches
178179
for ``MLXTurboQuantKVCache`` (~3.8× cache memory savings).
179180
Sliding-window layers are unaffected.
181+
max_write_len: Largest single prefill chunk written to the sliding
182+
ring caches (the export-time max prefill / dynamic seq_len bound),
183+
clamped to ``sliding_window``. Sizes the ring buffer to
184+
``sliding_window + max_write_len - 1``. If None, defaults to the
185+
full window, i.e. ``2 * sliding_window - 1``.
180186
"""
181187
config = model.config
182188

183189
for layer in model.layers:
184190
attn = layer.self_attn
185191

186192
if attn.is_sliding:
193+
# A single write into a sliding ring buffer can never exceed the
194+
# window, so clamp the chunk size to the window. Tiny test configs
195+
# can have sliding_window < the export max prefill.
196+
sliding_write_len = (
197+
min(max_write_len, config.sliding_window)
198+
if max_write_len is not None
199+
else None
200+
)
187201
attn.kv_cache = MLXRingKVCache(
188202
max_batch_size=1,
189203
max_context_length=config.sliding_window,
190204
n_heads=attn.n_kv_heads,
191205
head_dim=attn.head_dim,
192206
dtype=dtype,
207+
max_write_len=sliding_write_len,
193208
)
194209
attn.is_turboquant = False
195210
elif use_turboquant:

0 commit comments

Comments
 (0)