Skip to content

Commit 6bbb75a

Browse files
[MLX] Add off-graph KV cache ring runtime (pytorch#21532)
Summary Adds ring (sliding-window) layers to the MLX KV cache, so a model can mix them with flat layers per layer. The window is expressed declaratively: AttendSpec gains a window on Causal (the mask_mod axis the header reserved), so the cache states the semantic and the handler materializes whatever the backend can't do natively. MLX SDPA has only ""/"causal"/"array" — no sliding-window mode — so the handler builds a banded bool mask, but only when the window is narrower than the span. A backend that supports windows natively would just forward the integer. Files - backends/mlx/runtime/MLXCache.h — AttendSpec gains std::optional<int> window (Causal only; unset = unbounded history). - backends/mlx/runtime/MLXInterpreter.h — window_causal_mask(T, S, window) builds the [1,1,T,S] band from two arange compares (on-device, no host sync); the handler uses it only when window < S, else plain "causal". - backends/mlx/runtime/MLXSequenceCache.h — ring layers size their pool at window + max_write - 1 and are allocated outright rather than grown (a ring is already bounded, and growing it would complicate the modulo the planner applied); per-layer window is recorded and declared on multi-token steps; write_runs/read_runs scatter and gather the 1–2 runs a wrapping step produces; the construction-time ring rejection is removed. - backends/mlx/test/mlx_sequence_cache_test.cpp — four cases. Testing New: the mask is a band (checked against a hand-written 3×5 matrix, plus a window covering the span degenerating to causal); a ring decode past its window evicts the oldest and needs no mask; a multi-token step declares its window while a flat layer declares none; and a step whose runs wrap the ring is scattered and rejoined in logical order; the physical layout is out of order by then, so this pins the wrap handling. cmake --preset mlx-release -DEXECUTORCH_BUILD_TESTS=ON cmake --build cmake-out --target mlx_sequence_cache_test ctest --test-dir cmake-out -R mlx_sequence_cache --output-on-failure
1 parent e0a91fe commit 6bbb75a

3 files changed

Lines changed: 253 additions & 35 deletions

File tree

‎backends/mlx/runtime/MLXCache.h‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ namespace executorch {
1616
namespace backends {
1717
namespace mlx {
1818

19-
// The K/V window to attend over + how to mask it; the cache owns the semantic.
20-
// `kind` mirrors MLX SDPA's mask forms (no mask / "causal" / explicit tensor).
21-
// Will be added: a `window` on Causal (sliding-window -- the
22-
// mask_mod axis) and a `softcap`/bias field (ALiBi, softcap -- the score_mod
23-
// axis).
19+
// The K/V window to attend over + how to mask it; the cache owns the semantic
20+
// and hands over whatever MLX SDPA needs to apply it. `kind` mirrors MLX's mask
21+
// forms: no mask, its fused "causal", or an explicit tensor for anything MLX
22+
// cannot express -- a sliding window, and later tree/speculative patterns.
2423
struct AttendSpec {
2524
Tensor K;
2625
Tensor V;

‎backends/mlx/runtime/MLXSequenceCache.h‎

Lines changed: 102 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,33 @@ class Pool {
117117
Tensor buf_;
118118
};
119119

120+
// Bool mask [1, 1, T, S] for T queries over a span of S keys, where each query
121+
// attends at most `window` keys ending at itself. The span is right-aligned
122+
// (the newest key belongs to the last query), so query i spans keys
123+
// j - i <= S - T -- the same bound MLX's "causal" applies -- and the window
124+
// adds the lower bound j - i > S - T - window.
125+
inline Tensor window_causal_mask(int T, int S, int window, StreamOrDevice s) {
126+
using namespace ::mlx::core;
127+
Tensor diff = subtract(
128+
reshape(arange(S, int32, s), Shape{1, S}, s),
129+
reshape(arange(T, int32, s), Shape{T, 1}, s),
130+
s);
131+
const int hi = S - T;
132+
Tensor band = logical_and(
133+
less_equal(diff, array(hi), s),
134+
greater_equal(diff, array(hi - window + 1), s),
135+
s);
136+
return reshape(band, Shape{1, 1, T, S}, s);
137+
}
138+
120139
// The MLX byte layer behind the neutral SequenceCache: one Pool per layer for K
121140
// and one for V, sized from that layer's own policy. update_and_fetch plans the
122141
// step (integer runs), writes the new K/V, reads the retained window, and
123142
// declares the mask; the op handler owns q/scale and calls SDPA.
124143
//
125-
// Only flat layers are wired up so far. Ring layers are rejected at
126-
// construction rather than silently mis-served: the plumbing below handles
127-
// their runs, but the windowed mask and read_base_pos (RoPE alignment) are not
128-
// implemented yet.
144+
// Flat and ring layers can be mixed (gemma4 alternates them): the planner emits
145+
// physical runs either way, so they differ here only in how many slots the pool
146+
// holds and whether a step's runs wrap.
129147
class MLXSequenceCache : public cache::SequenceCache, public MLXCache {
130148
public:
131149
explicit MLXSequenceCache(const cache::CacheConfig& cfg)
@@ -134,21 +152,21 @@ class MLXSequenceCache : public cache::SequenceCache, public MLXCache {
134152
resolve_dtype(static_cast<int8_t>(cfg.kv_dtype));
135153
kpool_.reserve(static_cast<size_t>(cfg.n_layers));
136154
vpool_.reserve(static_cast<size_t>(cfg.n_layers));
155+
window_.reserve(static_cast<size_t>(cfg.n_layers));
137156
for (int l = 0; l < cfg.n_layers; ++l) {
138157
// layers size 1 = one config broadcast to every layer, else per-layer.
139158
const cache::LayerConfig& lc =
140159
cfg.layers.size() == 1 ? cfg.layers.front() : cfg.layers[l];
141-
if (lc.policy.kind != cache::LayerPolicy::Kind::Flat) {
142-
throw std::runtime_error(
143-
"MLXSequenceCache: only Flat layers are supported so far");
144-
}
145-
// Flat retains all history, so the layer's pool may reach the full cap. A
146-
// ring layer will instead cap at window + max_write - 1 slots.
147-
const int max_slots = cfg.capacity;
148-
kpool_.emplace_back(
149-
cfg.initial_capacity, max_slots, lc.n_kv_heads, lc.head_dim, dt);
150-
vpool_.emplace_back(
151-
cfg.initial_capacity, max_slots, lc.n_kv_heads, lc.head_dim, dt);
160+
const bool ring = lc.policy.kind == cache::LayerPolicy::Kind::Ring;
161+
window_.push_back(ring ? lc.policy.window : 0);
162+
// Flat retains all history, so its pool may reach the full cap and starts
163+
// small. A ring layer recycles a fixed window + max_write - 1 slots.
164+
const int max_slots = ring ? lc.policy.window +
165+
(cfg.max_write ? *cfg.max_write : lc.policy.window) - 1
166+
: cfg.capacity;
167+
const int initial = ring ? max_slots : cfg.initial_capacity;
168+
kpool_.emplace_back(initial, max_slots, lc.n_kv_heads, lc.head_dim, dt);
169+
vpool_.emplace_back(initial, max_slots, lc.n_kv_heads, lc.head_dim, dt);
152170
}
153171
}
154172

@@ -168,27 +186,80 @@ class MLXSequenceCache : public cache::SequenceCache, public MLXCache {
168186
throw std::runtime_error(
169187
"update_and_fetch: step exceeds capacity or invalid layer");
170188
}
171-
// Flat layers never wrap, and ring layers are refused at construction, so
172-
// every accepted step is a single run. Ring's split/rejoin lands with ring.
173-
if (p->n_write != 1 || p->n_read != 1) {
174-
throw std::runtime_error("update_and_fetch: expected a single-run plan");
175-
}
176189
const size_t l = static_cast<size_t>(layer);
177-
kpool_[l].write(p->write[0], k, s);
178-
vpool_[l].write(p->write[0], v, s);
179-
Tensor K = kpool_[l].read(p->read[0], s);
180-
Tensor V = vpool_[l].read(p->read[0], s);
190+
write_runs(kpool_[l], p->write, p->n_write, k, s);
191+
write_runs(vpool_[l], p->write, p->n_write, v, s);
192+
Tensor K = read_runs(kpool_[l], p->read, p->n_read, s);
193+
Tensor V = read_runs(vpool_[l], p->read, p->n_read, s);
181194
this->commit(*p);
182195

183-
// A multi-token chain is Causal (MLX "causal" is lower-right aligned, so
184-
// fresh and chunked prefill are both correct with new tokens at the tail);
185-
// a single decode token needs no mask.
186-
const AttendSpec::Mask kind =
187-
(T > 1) ? AttendSpec::Mask::Causal : AttendSpec::Mask::None;
188-
return AttendSpec{K, V, kind, std::nullopt};
196+
// A single decode token needs no mask -- its span is exactly what it may
197+
// attend, on a ring layer as much as a flat one.
198+
if (T == 1) {
199+
return AttendSpec{K, V, AttendSpec::Mask::None, std::nullopt};
200+
}
201+
// A ring layer bounds each query to its own window. That only bites when
202+
// the window is narrower than the span (a multi-token step reads the union
203+
// of its queries' windows, window + T - 1 cells); otherwise plain causal is
204+
// exact and MLX applies it fused, with no mask tensor.
205+
const int S = static_cast<int>(K.shape(2));
206+
if (window_[l] > 0 && window_[l] < S) {
207+
return AttendSpec{
208+
K,
209+
V,
210+
AttendSpec::Mask::Explicit,
211+
window_causal_mask(T, S, window_[l], s)};
212+
}
213+
// MLX "causal" is lower-right aligned, so fresh and chunked prefill are
214+
// both correct with the new tokens at the tail.
215+
return AttendSpec{K, V, AttendSpec::Mask::Causal, std::nullopt};
189216
}
190217

191218
private:
219+
// Scatter `update` across the step's runs. Runs are in logical order, so
220+
// consecutive slices of `update` map to consecutive runs. A flat step is one
221+
// run; a ring step splits in two when it wraps the pool.
222+
static void write_runs(
223+
Pool& pool,
224+
const cache::Run* runs,
225+
int n,
226+
const Tensor& update,
227+
StreamOrDevice s) {
228+
if (n == 1) {
229+
pool.write(runs[0], update, s);
230+
return;
231+
}
232+
const int H = static_cast<int>(update.shape(1));
233+
const int D = static_cast<int>(update.shape(3));
234+
int off = 0;
235+
for (int i = 0; i < n; ++i) {
236+
pool.write(
237+
runs[i],
238+
::mlx::core::slice(
239+
update,
240+
::mlx::core::Shape{0, 0, off, 0},
241+
::mlx::core::Shape{1, H, off + runs[i].len, D},
242+
::mlx::core::Shape{1, 1, 1, 1},
243+
s),
244+
s);
245+
off += runs[i].len;
246+
}
247+
}
248+
249+
// Gather the step's runs into the retained window, oldest -> newest.
250+
static Tensor
251+
read_runs(const Pool& pool, const cache::Run* runs, int n, StreamOrDevice s) {
252+
if (n == 1) {
253+
return pool.read(runs[0], s);
254+
}
255+
std::vector<Tensor> parts;
256+
parts.reserve(static_cast<size_t>(n));
257+
for (int i = 0; i < n; ++i) {
258+
parts.push_back(pool.read(runs[i], s));
259+
}
260+
return ::mlx::core::concatenate(parts, 2, s);
261+
}
262+
192263
// Enforce the neutral contract as an exception, the failure mode this layer
193264
// already uses. Runs as the base initializer's argument because
194265
// SequenceCache's own ctor indexes `layers` before this class's body does.
@@ -201,6 +272,7 @@ class MLXSequenceCache : public cache::SequenceCache, public MLXCache {
201272

202273
std::vector<Pool> kpool_;
203274
std::vector<Pool> vpool_;
275+
std::vector<int> window_; // per layer; 0 = flat (unbounded history)
204276
};
205277

206278
} // namespace mlx

‎backends/mlx/test/mlx_sequence_cache_test.cpp‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ cache::CacheConfig flat_config(
6363
return cfg;
6464
}
6565

66+
// One ring layer of `window` cells; max_write bounds a multi-token step.
67+
cache::CacheConfig ring_config(
68+
int capacity,
69+
int window,
70+
int max_write,
71+
int n_kv_heads,
72+
int head_dim,
73+
int kv_dtype) {
74+
cache::CacheConfig cfg =
75+
flat_config(capacity, /*n_layers=*/1, n_kv_heads, head_dim, kv_dtype);
76+
cfg.layers[0].policy =
77+
cache::LayerPolicy{cache::LayerPolicy::Kind::Ring, window};
78+
cfg.max_write = max_write;
79+
return cfg;
80+
}
81+
6682
class MLXSequenceCacheTest : public ::testing::Test {
6783
protected:
6884
const int H = 2;
@@ -260,6 +276,137 @@ TEST_F(MLXSequenceCacheTest, ZeroInitialCapacityGrowsOnFirstWrite) {
260276
EXPECT_TRUE(allclose(spec0.K, k0, 0.0f));
261277
}
262278

279+
// The sliding-window mask is a band on (key - query): causal above, window
280+
// below. The span is right-aligned, so the last query's own key is the last
281+
// key, and each query attends `window` keys ending at its own.
282+
TEST_F(MLXSequenceCacheTest, WindowCausalMaskIsABand) {
283+
using namespace ::mlx::core;
284+
// T=3 queries over S=5 keys, window 3. Query i owns key i + (S - T), and
285+
// attends the `window` keys ending there -- a band, one row per query.
286+
// clang-format off
287+
std::vector<int> want = {
288+
1, 1, 1, 0, 0,
289+
0, 1, 1, 1, 0,
290+
0, 0, 1, 1, 1};
291+
// A window covering the whole span leaves plain causal: no lower bound bites.
292+
std::vector<int> causal = {
293+
1, 1, 1, 0, 0,
294+
1, 1, 1, 1, 0,
295+
1, 1, 1, 1, 1};
296+
// clang-format on
297+
array m = astype(window_causal_mask(3, 5, 3, s), int32, s);
298+
EXPECT_TRUE(allclose(m, array(want.data(), Shape{1, 1, 3, 5}, int32), 0.0f));
299+
300+
array full = astype(window_causal_mask(3, 5, 5, s), int32, s);
301+
EXPECT_TRUE(
302+
allclose(full, array(causal.data(), Shape{1, 1, 3, 5}, int32), 0.0f));
303+
}
304+
305+
// A ring layer decodes past its window: the read span stops at `window` cells
306+
// and holds the newest tokens, evicting the oldest. No mask -- a single query
307+
// may attend its whole span.
308+
TEST_F(MLXSequenceCacheTest, RingDecodeEvictsOldestAndNeedsNoMask) {
309+
using namespace ::mlx::core;
310+
const int W = 4;
311+
MLXSequenceCache c(ring_config(
312+
/*capacity=*/64,
313+
/*window=*/W,
314+
/*max_write=*/1,
315+
H,
316+
D,
317+
static_cast<int>(ScalarType::Half)));
318+
319+
// Feed 6 single tokens; the last 4 must survive, oldest -> newest.
320+
std::vector<array> toks;
321+
for (int i = 0; i < 6; ++i) {
322+
toks.push_back(randn(1, float16));
323+
}
324+
AttendSpec spec{toks[0], toks[0], AttendSpec::Mask::None, {}};
325+
for (int i = 0; i < 6; ++i) {
326+
spec = c.update_and_fetch(0, /*position=*/i, toks[i], toks[i], s);
327+
}
328+
EXPECT_EQ(spec.kind, AttendSpec::Mask::None);
329+
EXPECT_EQ(spec.K.shape(2), W);
330+
EXPECT_TRUE(allclose(
331+
spec.K,
332+
concatenate(std::vector<array>{toks[2], toks[3], toks[4], toks[5]}, 2, s),
333+
0.0f));
334+
}
335+
336+
// A ring layer stays Causal while its window still covers the span, so MLX
337+
// applies its fused mask and no tensor is built. A flat layer always does.
338+
TEST_F(MLXSequenceCacheTest, WindowWiderThanSpanStaysCausal) {
339+
using namespace ::mlx::core;
340+
MLXSequenceCache ring(ring_config(
341+
/*capacity=*/64,
342+
/*window=*/4,
343+
/*max_write=*/2,
344+
H,
345+
D,
346+
static_cast<int>(ScalarType::Half)));
347+
array k = randn(2, float16); // span 2 < window 4
348+
AttendSpec rspec = ring.update_and_fetch(0, /*position=*/0, k, k, s);
349+
EXPECT_EQ(rspec.kind, AttendSpec::Mask::Causal);
350+
EXPECT_FALSE(rspec.mask.has_value());
351+
352+
MLXSequenceCache flat(flat_config(
353+
/*capacity=*/64,
354+
/*n_layers=*/1,
355+
H,
356+
D,
357+
static_cast<int>(ScalarType::Half)));
358+
AttendSpec fspec = flat.update_and_fetch(0, /*position=*/0, k, k, s);
359+
EXPECT_EQ(fspec.kind, AttendSpec::Mask::Causal);
360+
EXPECT_FALSE(fspec.mask.has_value());
361+
}
362+
363+
// A step whose runs wrap the ring is scattered and gathered in logical order.
364+
TEST_F(MLXSequenceCacheTest, RingStepWrapsAndRejoinsInOrder) {
365+
using namespace ::mlx::core;
366+
const int W = 4;
367+
const int MW = 2;
368+
MLXSequenceCache c(ring_config(
369+
/*capacity=*/64,
370+
/*window=*/W,
371+
/*max_write=*/MW,
372+
H,
373+
D,
374+
static_cast<int>(ScalarType::Half)));
375+
376+
// ring_size = W + MW - 1 = 5. Fill 4 tokens, then a 2-token step at
377+
// position 4 writes slots 4 and 0 -- a wrap.
378+
std::vector<array> toks;
379+
for (int i = 0; i < 4; ++i) {
380+
toks.push_back(randn(1, float16));
381+
c.update_and_fetch(0, /*position=*/i, toks.back(), toks.back(), s);
382+
}
383+
array pair = randn(2, float16);
384+
AttendSpec spec = c.update_and_fetch(0, /*position=*/4, pair, pair, s);
385+
386+
// The span is the union of the two queries' windows -- position 4 attends
387+
// 1..4 and position 5 attends 2..5 -- so it is window + T - 1 = 5 cells, not
388+
// 4. Since the window is now narrower than the span, the cache hands back a
389+
// band to narrow each query back to its own 4.
390+
EXPECT_EQ(spec.K.shape(2), W + 1);
391+
EXPECT_EQ(spec.kind, AttendSpec::Mask::Explicit);
392+
ASSERT_TRUE(spec.mask.has_value());
393+
// Span indices 0..4 are positions 1..5. Query 0 is position 4 and attends
394+
// 1..4; query 1 is position 5 and attends 2..5 -- four keys each.
395+
// clang-format off
396+
std::vector<int> band = {
397+
1, 1, 1, 1, 0,
398+
0, 1, 1, 1, 1};
399+
// clang-format on
400+
EXPECT_TRUE(allclose(
401+
astype(*spec.mask, int32, s),
402+
array(band.data(), Shape{1, 1, 2, W + 1}, int32),
403+
0.0f));
404+
EXPECT_TRUE(allclose(
405+
spec.K,
406+
concatenate(std::vector<array>{toks[1], toks[2], toks[3], pair}, 2, s),
407+
0.0f));
408+
}
409+
263410
// A negative initial_capacity is rejected rather than reaching MLX as a
264411
// negative dimension.
265412
TEST_F(MLXSequenceCacheTest, NegativeInitialCapacityThrows) {

0 commit comments

Comments
 (0)