Skip to content

Commit 3389d51

Browse files
Add functional update_and_attend KV-cache op (#21112)
**Summary** Adds a neutral kvcache::update_and_attend custom op for KV-cache attention. The op is functional to the tracer with the cache held off-graph via a process-global registry, so the exported .pte does not bake in cache size etc. Ships an eager reference cache (ContiguousReferenceCache) with runtime-selectable static/dynamic sizing and a hard capacity bound. - op_update_and_attend.py (new) — the kvcache::update_and_attend custom op and the off-graph cache registry (install_cache/set_active/uninstall_cache). - op_update_and_attend_reference.py (new) — CacheConfig/AttendSpec and ContiguousReferenceCache (update_and_fetch, static/dynamic sizing, hard capacity) plus the neutral attend SDPA mechanism. - test_update_and_attend.py (new) — functional-graph + prefill/decode parity tests across static and dynamic sizing. - targets.bzl (modified) — adds the update_and_attend_py python_library. - BUCK (modified) — adds the test_update_and_attend python_test. **Test plan** pytest -v extension/llm/custom_ops/test_update_and_attend.py Verifies the exported graph is functional (no buffer inputs/mutations, one op call per layer) and that outputs match cacheless causal attention for prefill and incremental decode under both static and dynamic sizing.
1 parent 2cce41f commit 3389d51

6 files changed

Lines changed: 590 additions & 0 deletions

File tree

extension/llm/cache/BUCK

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target", "non_fbcode_target")
2+
oncall("executorch")
3+
# Any targets that should be shared between fbcode and xplat must be defined in
4+
# targets.bzl. This file can contain xplat-only targets.
5+
6+
load(":targets.bzl", "define_common_targets")
7+
8+
9+
non_fbcode_target(_kind = define_common_targets,)
10+
11+
# Any targets that should be shared between fbcode and xplat must be defined in
12+
# targets.bzl. This file can contain fbcode-only targets.
13+
14+
load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime")
15+
load(":targets.bzl", "define_common_targets")
16+
17+
18+
fbcode_target(_kind = define_common_targets,)
19+
20+
fbcode_target(_kind = runtime.python_test,
21+
name = "test_update_and_attend",
22+
srcs = [
23+
"test_update_and_attend.py",
24+
],
25+
deps = [
26+
":cache",
27+
"//caffe2:torch",
28+
],
29+
)

extension/llm/cache/__init__.py

Whitespace-only changes.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Eager reference (oracle) KV cache behind ``kvcache::update_and_attend``.
8+
9+
This is off-graph runtime state: it never appears in the exported graph, so the
10+
physical sizing strategy is chosen here at construction time -- not baked into the
11+
``.pte``. Two sizings are supported:
12+
13+
* ``STATIC`` -- preallocate a buffer of ``capacity`` cells; the used region
14+
advances within it (no realloc; models the static-shape backend constraint).
15+
* ``DYNAMIC`` -- start empty and grow the used region lazily, up to ``capacity``.
16+
17+
Either way the cache bounds hard at ``capacity`` (required): memory grows lazily
18+
but is capped, per the design's "grows lazily and bounds hard".
19+
20+
The cache places K/V and returns the history plus an ``AttendSpec`` (a mask *semantic*). The attend
21+
mechanism (``attend`` below) is applied by the op/backend from that spec.
22+
23+
Scope for this initial slice: single sequence, contiguous placement, float KV.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from dataclasses import dataclass
29+
from enum import Enum
30+
from typing import List, Tuple
31+
32+
import torch
33+
import torch.nn.functional as F
34+
35+
from executorch.exir._warnings import experimental
36+
37+
38+
class CacheSizing(Enum):
39+
STATIC = "static"
40+
DYNAMIC = "dynamic"
41+
42+
43+
class MaskKind(Enum):
44+
NONE = "none" # decode: q_len == 1, the single query sees all of history
45+
CAUSAL = "causal" # prefill/continuation: query i sees keys up to its position
46+
47+
48+
@dataclass
49+
class AttendSpec:
50+
kind: MaskKind
51+
52+
53+
@experimental(
54+
"update_and_attend KV cache is experimental and may change without notice."
55+
)
56+
@dataclass
57+
class CacheConfig:
58+
n_layers: int
59+
n_kv_heads: int
60+
head_dim: int
61+
capacity: int # hard bound in cells; the cache never exceeds it
62+
sizing: CacheSizing = CacheSizing.DYNAMIC
63+
dtype: torch.dtype = torch.float32
64+
batch_size: int = 1
65+
66+
def __post_init__(self):
67+
if self.capacity <= 0:
68+
raise ValueError("capacity must be positive")
69+
70+
71+
@experimental(
72+
"update_and_attend KV cache is experimental and may change without notice."
73+
)
74+
class ContiguousReferenceCache:
75+
"""Per-layer contiguous float KV history for a single sequence."""
76+
77+
def __init__(self, config: CacheConfig):
78+
self.config = config
79+
self._k: List[torch.Tensor] = []
80+
self._v: List[torch.Tensor] = []
81+
self._used: List[int] = [0] * config.n_layers
82+
b, h, d = config.batch_size, config.n_kv_heads, config.head_dim
83+
init_len = config.capacity if config.sizing == CacheSizing.STATIC else 0
84+
for _ in range(config.n_layers):
85+
self._k.append(torch.zeros(b, h, init_len, d, dtype=config.dtype))
86+
self._v.append(torch.zeros(b, h, init_len, d, dtype=config.dtype))
87+
88+
def used(self, layer_id: int) -> int:
89+
return self._used[layer_id]
90+
91+
def reset(self):
92+
self._used = [0] * self.config.n_layers
93+
if self.config.sizing == CacheSizing.DYNAMIC:
94+
b, h, d = (
95+
self.config.batch_size,
96+
self.config.n_kv_heads,
97+
self.config.head_dim,
98+
)
99+
for i in range(self.config.n_layers):
100+
self._k[i] = torch.zeros(b, h, 0, d, dtype=self.config.dtype)
101+
self._v[i] = torch.zeros(b, h, 0, d, dtype=self.config.dtype)
102+
103+
def update_and_fetch(
104+
self,
105+
layer_id: int,
106+
k: torch.Tensor,
107+
v: torch.Tensor,
108+
position: torch.Tensor,
109+
) -> Tuple[torch.Tensor, torch.Tensor, AttendSpec]:
110+
"""Place this step's K/V and return the full history + mask semantic.
111+
112+
Per the design, ``position`` is the cache's placement + masking input.
113+
This contiguous single-sequence cache appends at its used length, so the
114+
causal offset is that prior length; non-contiguous (tree) caches will
115+
consume ``position`` directly to place and to build an Explicit mask.
116+
117+
Args (BHSD):
118+
layer_id: which layer's history to update.
119+
k: ``[B, H_kv, q_len, head_dim]`` -- new keys for this step.
120+
v: ``[B, H_kv, q_len, v_head_dim]`` -- new values (``v_head_dim`` may
121+
differ from ``head_dim``, e.g. MLA).
122+
position: ``[q_len, n_dims]`` int -- per-query-token positions.
123+
124+
Returns:
125+
``(k_hist, v_hist, spec)`` -- history ``[B, H_kv, total, head_dim]`` /
126+
``[B, H_kv, total, v_head_dim]`` (``total`` = prior length + q_len) and
127+
the AttendSpec mask semantic.
128+
"""
129+
q_len = k.shape[-2]
130+
used = self._used[layer_id]
131+
new_used = used + q_len
132+
cap = self.config.capacity
133+
if new_used > cap:
134+
raise RuntimeError(
135+
f"KV cache overflow on layer {layer_id}: "
136+
f"{new_used} cells exceeds capacity {cap}"
137+
)
138+
139+
k = k.to(self.config.dtype)
140+
v = v.to(self.config.dtype)
141+
if self.config.sizing == CacheSizing.STATIC:
142+
self._k[layer_id][:, :, used:new_used, :] = k
143+
self._v[layer_id][:, :, used:new_used, :] = v
144+
k_hist = self._k[layer_id][:, :, :new_used, :]
145+
v_hist = self._v[layer_id][:, :, :new_used, :]
146+
else:
147+
self._k[layer_id] = torch.cat([self._k[layer_id], k], dim=2)
148+
self._v[layer_id] = torch.cat([self._v[layer_id], v], dim=2)
149+
k_hist = self._k[layer_id]
150+
v_hist = self._v[layer_id]
151+
self._used[layer_id] = new_used
152+
153+
kind = MaskKind.NONE if q_len == 1 else MaskKind.CAUSAL
154+
return k_hist, v_hist, AttendSpec(kind=kind)
155+
156+
157+
def attend(
158+
q: torch.Tensor,
159+
k: torch.Tensor,
160+
v: torch.Tensor,
161+
spec: AttendSpec,
162+
scale: float,
163+
out_dtype: torch.dtype,
164+
) -> torch.Tensor:
165+
"""Eager attend mechanism: SDPA over fetched K/V per the mask semantic.
166+
167+
Repeats K/V heads for GQA/MQA (``H_q`` a multiple of ``H_kv``), casts to fp32,
168+
and calls ``F.scaled_dot_product_attention`` -- causal for CAUSAL (the cache is
169+
contiguous, so causal alignment matches the prior length), unmasked for NONE.
170+
171+
Args (BHSD):
172+
q: ``[B, H_q, q_len, head_dim]`` -- queries (already RoPE-rotated).
173+
k: ``[B, H_kv, total, head_dim]`` -- key history.
174+
v: ``[B, H_kv, total, v_head_dim]`` -- value history.
175+
spec: mask semantic (NONE = attend all; CAUSAL = causal).
176+
scale: attention softmax scale.
177+
out_dtype: output dtype.
178+
179+
Returns:
180+
``[B, H_q, q_len, v_head_dim]`` attention output, in ``out_dtype``.
181+
"""
182+
n_q_heads = q.shape[1]
183+
n_kv_heads = k.shape[1]
184+
if n_q_heads != n_kv_heads:
185+
rep = n_q_heads // n_kv_heads
186+
k = k.repeat_interleave(rep, dim=1)
187+
v = v.repeat_interleave(rep, dim=1)
188+
189+
out = F.scaled_dot_product_attention(
190+
q.to(torch.float32),
191+
k.to(torch.float32),
192+
v.to(torch.float32),
193+
is_causal=spec.kind != MaskKind.NONE,
194+
scale=scale,
195+
)
196+
return out.to(out_dtype)

extension/llm/cache/targets.bzl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime")
2+
3+
4+
def define_common_targets():
5+
runtime.python_library(
6+
name = "cache",
7+
srcs = [
8+
"reference_cache.py",
9+
"update_and_attend.py",
10+
],
11+
visibility = ["PUBLIC"],
12+
deps = [
13+
"//caffe2:torch",
14+
],
15+
)

0 commit comments

Comments
 (0)