Skip to content

Commit ad2de69

Browse files
Optimize CPU SDPA for ring attention (pytorch#22358)
## Summary - skip fully masked KV blocks and trim masked boundaries in CPU custom SDPA - split wrapped ring windows around large masked gaps while preserving additive-mask semantics - size local KV caches as sliding window plus the maximum in-flight prefill chunk, capped by full context - support regular, custom-op, and quantized ring caches
1 parent 5b77187 commit ad2de69

10 files changed

Lines changed: 794 additions & 181 deletions

File tree

‎examples/models/llama/attention.py‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,23 @@ def calculate_positions_and_update_indices(self, input_pos: torch.Tensor, seq_le
262262
return indices
263263

264264

265+
def _get_ring_cache_size(
266+
max_context_length: int,
267+
window_size: int,
268+
max_seq_len: Optional[int] = None,
269+
) -> int:
270+
"""Size an SWA cache for one retained window plus one in-flight chunk."""
271+
assert window_size > 0, "Sliding-window size must be positive"
272+
if max_seq_len is None:
273+
max_seq_len = max_context_length
274+
assert max_seq_len > 0, "Maximum sequence length must be positive"
275+
assert window_size <= max_context_length, (
276+
f"Sliding-window size ({window_size}) cannot exceed the full context "
277+
f"length ({max_context_length})"
278+
)
279+
return min(max_context_length, window_size + max_seq_len)
280+
281+
265282
class RingKVCache(KVCache):
266283
def __init__(
267284
self,
@@ -271,10 +288,17 @@ def __init__(
271288
head_dim: int,
272289
enable_dynamic_shape: bool,
273290
dtype=torch.float32,
291+
*,
292+
window_size: int,
293+
max_seq_len: Optional[int] = None,
274294
):
275-
self.window_size = max_context_length
276295
"""
277-
Reason why we want the kv cache size to be twice the context length:
296+
The cache needs room for the retained sliding window and the current
297+
prefill chunk. Its size is window_size + max_seq_len, capped by the
298+
full-context cache. If max_seq_len is omitted, the full context length
299+
is used.
300+
301+
Reason why a cache larger than the sliding window is needed:
278302
Sliding window attention without ringbuffer
279303
pos 0 1 2 3 4 5 6 7 8 9 10
280304
0 x 0 0 0 0 0 0 0 0 0 0
@@ -307,20 +331,28 @@ def __init__(
307331
So not having kept 2, 3 and 4 in cache means we will have divergent behavior.
308332
Worst case of this would have been when update it equal to the length of
309333
the cache. like in our case pos = 5 seq len = 4.
310-
Thus we need to have a cache that is larger. How much larger, as much as
311-
the sliding window size. So twice the max_context_length.
334+
Thus we need a cache larger than the sliding window by enough space for
335+
the largest in-flight input chunk.
312336
How would that have helped. Lets see. At pos = 5 our cache would have
313337
[0, 1, 2, 3, 4, NA, NA, NA] After cache update we would have
314338
[8, 1, 2, 3, 4, 5, 6, 7]. We kicked out token at pos = 0. However, the
315339
current step still has access to [pos - sliding_window_size, pos] tokens.
316-
340+
317341
To make sure we dont over attend, i.e. we dont have pos = 5
318342
to attend to pos = 1, mask calculaton has to account for the sliding window
319343
size.
320344
"""
345+
self.window_size = window_size
346+
self.full_context_length = max_context_length
347+
self.max_seq_len = (
348+
max_context_length if max_seq_len is None else int(max_seq_len)
349+
)
350+
ring_cache_size = _get_ring_cache_size(
351+
max_context_length, window_size, self.max_seq_len
352+
)
321353
super().__init__(
322354
max_batch_size,
323-
max_context_length * 2,
355+
ring_cache_size,
324356
n_heads,
325357
head_dim,
326358
enable_dynamic_shape,

‎examples/models/llama/export_llama_lib.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,7 @@ def _prepare_for_llama_export(llm_config: LlmConfig) -> LLMEdgeManager:
969969
preq_group_size=llm_config.base.preq_group_size,
970970
preq_embedding_quantize=llm_config.base.preq_embedding_quantize,
971971
local_global_attention=llm_config.model.local_global_attention,
972+
max_seq_len=llm_config.export.max_seq_length,
972973
use_torchao_kernels_linear=llm_config.backend.torchao.use_torchao_kernels_linear,
973974
use_torchao_kernels_tied_embedding=llm_config.backend.torchao.use_torchao_kernels_tied_embedding,
974975
quantize_with_hqq=llm_config.quantization.use_hqq,
@@ -2064,6 +2065,7 @@ def _get_source_transforms( # noqa
20642065
preq_group_size: Optional[int] = None,
20652066
preq_embedding_quantize: Optional[str] = None,
20662067
local_global_attention: Optional[List[int]] = None,
2068+
max_seq_len: Optional[int] = None,
20672069
use_torchao_kernels_linear: bool = False,
20682070
use_torchao_kernels_tied_embedding: bool = False,
20692071
quantize_with_hqq: bool = True,
@@ -2104,6 +2106,8 @@ def _get_source_transforms( # noqa
21042106
preq_mode: Pre-quantization mode.
21052107
preq_group_size: Pre-quantization group size.
21062108
preq_embedding_quantize: Pre-quantization embedding quantize.
2109+
max_seq_len: Largest input chunk accepted by local-attention layers. If
2110+
omitted, each layer's full context length is used.
21072111
21082112
Returns:
21092113
A list of transformation functions.
@@ -2280,6 +2284,7 @@ def _get_source_transforms( # noqa
22802284
partial(
22812285
replace_kv_cache_with_ring_kv_cache,
22822286
layer_sizes=local_global_attention,
2287+
max_seq_len=max_seq_len,
22832288
)
22842289
)
22852290

‎examples/models/llama/source_transformation/attention_sink.py‎

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,36 @@
1818
_create_causal_mask_for_ring_buffer,
1919
AttentionMHA,
2020
KVCache,
21-
RingKVCache,
2221
)
2322
from executorch.examples.models.llama.model_args import ModelArgs
2423
from executorch.examples.models.llama.rope import Rope
2524
from torchao.quantization.quant_api import _replace_with_custom_fn_if_matches_filter
2625

2726

27+
def _get_attention_sink_cache_size(
28+
max_context_length: int,
29+
window_size: int,
30+
sink_size: int,
31+
max_seq_len: Optional[int] = None,
32+
) -> int:
33+
"""Size a sink cache for fixed sinks, one window, and one input chunk."""
34+
assert sink_size >= 0, "Attention sink size must be non-negative"
35+
assert sink_size < max_context_length, (
36+
f"Attention sink size ({sink_size}) must be smaller than the full "
37+
f"context length ({max_context_length})"
38+
)
39+
assert window_size > 0, "Sliding-window size must be positive"
40+
assert sink_size + window_size <= max_context_length, (
41+
f"Attention sink size ({sink_size}) plus sliding-window size "
42+
f"({window_size}) cannot exceed the full context length "
43+
f"({max_context_length})"
44+
)
45+
if max_seq_len is None:
46+
max_seq_len = max_context_length
47+
assert max_seq_len > 0, "Maximum sequence length must be positive"
48+
return sink_size + window_size + max_seq_len
49+
50+
2851
class RopeWithAttentionSink(Rope):
2952
"""
3053
Rope subclass for Attention Sink models.
@@ -37,10 +60,11 @@ class RopeWithAttentionSink(Rope):
3760
- Window tokens (pos >= sink_size): wrapped into ring buffer range
3861
[sink_size, sink_size + ring_size) via modulo
3962
40-
The ring buffer is 2x window_size for write-ahead headroom, not to keep the
41-
live window contiguous -- it can span a wrap. Across a wrap two positions
42-
remap to a difference that is not their true distance, so RoPE preserves
43-
relative distance only within a wrap.
63+
The ring buffer holds one retained window plus one maximum-size input
64+
chunk. It is larger than the live window for write-ahead headroom, not to
65+
keep the live window contiguous -- it can span a wrap. Across a wrap two
66+
positions remap to a difference that is not their true distance, so RoPE
67+
preserves relative distance only within a wrap.
4468
"""
4569

4670
def __init__(
@@ -52,7 +76,26 @@ def __init__(
5276
super().__init__(params)
5377
self.window_size = window_size
5478
self.sink_size = sink_size
55-
self.ring_size = window_size * 2
79+
max_seq_len = (
80+
params.max_context_len
81+
if getattr(params, "max_seq_len", None) is None
82+
else int(params.max_seq_len)
83+
)
84+
cache_size = _get_attention_sink_cache_size(
85+
params.max_context_len,
86+
window_size,
87+
sink_size,
88+
max_seq_len,
89+
)
90+
self.ring_size = cache_size - sink_size
91+
if self.freqs_cos.size(0) < cache_size:
92+
freqs_cos, freqs_sin = self.precompute_freqs_cis(
93+
self.params.head_dim,
94+
cache_size,
95+
self.params.rope_freq_base,
96+
)
97+
self.freqs_cos = freqs_cos
98+
self.freqs_sin = freqs_sin
5699

57100
def _remap_input_pos(self, input_pos: torch.Tensor) -> torch.Tensor:
58101
"""Remap positions: sink tokens stay, window tokens wrap in ring buffer."""
@@ -133,7 +176,7 @@ class CachePositionsManagerWithSink(nn.Module):
133176
For sink_size=0: behaves exactly like original CachePositionsManager.
134177
For sink_size>0: sink tokens go to fixed positions, rest uses ring buffer.
135178
136-
IMPORTANT: cache_size should be the actual cache dimension size (2x window for ring buffer).
179+
IMPORTANT: cache_size is the actual cache dimension, including sink slots.
137180
"""
138181

139182
def __init__(self, cache_size: int, sink_size: int = 0):
@@ -191,7 +234,7 @@ class KVCacheWithAttentionSink(KVCache):
191234
Uses a ring buffer approach for the sliding window portion while keeping
192235
the first sink_size tokens fixed. This avoids dynamic shape operations.
193236
194-
Cache layout: [sink: 0 to sink_size-1] [ring_buffer: sink_size to sink_size + window_size*2 - 1]
237+
Cache layout: [fixed sink tokens] [ring buffer for one window + one chunk]
195238
"""
196239

197240
def __init__(
@@ -202,16 +245,27 @@ def __init__(
202245
rope: RopeWithAttentionSink,
203246
window_size: int,
204247
sink_size: int,
248+
max_context_length: int,
249+
max_seq_len: Optional[int] = None,
205250
max_batch_size: int = 1,
206251
dtype=torch.float32,
207252
):
208-
# Total cache size is sink_size + window_size * 2.
209-
# The ring buffer needs 2x the window size because at the moment a new
210-
# token is written, the previous window_size tokens must still be readable
211-
# (they haven't been overwritten yet). With only 1x, writing a new entry
212-
# would immediately evict the oldest visible token, leaving fewer than
213-
# window_size tokens available for attention.
214-
total_cache_size = sink_size + window_size * 2
253+
self.full_context_length = max_context_length
254+
self.max_seq_len = (
255+
max_context_length if max_seq_len is None else int(max_seq_len)
256+
)
257+
# Keep the fixed sinks separate from the ring space needed for the
258+
# retained window and the largest in-flight input chunk.
259+
total_cache_size = _get_attention_sink_cache_size(
260+
max_context_length,
261+
window_size,
262+
sink_size,
263+
self.max_seq_len,
264+
)
265+
assert rope.ring_size == total_cache_size - sink_size, (
266+
f"RoPE ring size ({rope.ring_size}) must match the KV-cache ring "
267+
f"size ({total_cache_size - sink_size})"
268+
)
215269
super().__init__(
216270
max_batch_size=max_batch_size,
217271
max_context_length=total_cache_size,
@@ -310,6 +364,7 @@ def _replace_attention(
310364
rope_with_attention_sink: RopeWithAttentionSink,
311365
sink_size: int,
312366
window_size: int,
367+
max_seq_len: Optional[int],
313368
):
314369
for _, child_module in module._modules.items():
315370
if len(list(child_module.children())) > 0: # pyre-ignore [16]
@@ -318,32 +373,23 @@ def _replace_attention(
318373
rope_with_attention_sink=rope_with_attention_sink,
319374
sink_size=sink_size,
320375
window_size=window_size,
376+
max_seq_len=max_seq_len,
321377
)
322378

323379
if isinstance(child_module, AttentionMHA):
324380
kv_cache = child_module.kv_cache
325-
if sink_size == 0:
326-
# No sink tokens needed — use standard RingKVCache directly
327-
child_module.kv_cache = RingKVCache(
328-
kv_cache.max_batch_size,
329-
window_size, # RingKVCache expects user-provided window size
330-
kv_cache.n_heads,
331-
kv_cache.head_dim,
332-
kv_cache.enable_dynamic_shape,
333-
kv_cache.k_cache.dtype,
334-
)
335-
else:
336-
kv_cache_with_attention_sink = KVCacheWithAttentionSink(
337-
n_heads=kv_cache.n_heads,
338-
head_dim=kv_cache.head_dim,
339-
enable_dynamic_shape=kv_cache.enable_dynamic_shape,
340-
rope=rope_with_attention_sink,
341-
max_batch_size=kv_cache.max_batch_size,
342-
window_size=window_size,
343-
sink_size=sink_size,
344-
dtype=kv_cache.k_cache.dtype,
345-
)
346-
child_module.kv_cache = kv_cache_with_attention_sink
381+
child_module.kv_cache = KVCacheWithAttentionSink(
382+
n_heads=kv_cache.n_heads,
383+
head_dim=kv_cache.head_dim,
384+
enable_dynamic_shape=kv_cache.enable_dynamic_shape,
385+
rope=rope_with_attention_sink,
386+
max_batch_size=kv_cache.max_batch_size,
387+
window_size=window_size,
388+
sink_size=sink_size,
389+
max_context_length=kv_cache.max_context_length,
390+
max_seq_len=max_seq_len,
391+
dtype=kv_cache.k_cache.dtype,
392+
)
347393
# Don't replace forward - let the original AttentionMHA.forward handle it
348394
# since our KVCache has is_ring_buffer=True, it will use the ring buffer mask
349395

@@ -365,11 +411,13 @@ def enable_attention_sink(
365411
window_size=window_size,
366412
sink_size=sink_size,
367413
)
414+
max_seq_len = getattr(params, "max_seq_len", None)
368415
_replace_rope(module, rope_with_attention_sink)
369416
_replace_attention(
370417
module=module,
371418
rope_with_attention_sink=rope_with_attention_sink,
372419
sink_size=sink_size,
373420
window_size=window_size,
421+
max_seq_len=max_seq_len,
374422
)
375423
return module

0 commit comments

Comments
 (0)