Skip to content

Commit 849f9d8

Browse files
[MLX] Grow the KV-cache pool on demand (#21525)
Summary Makes the MLX KV-cache pool grow on demand instead of allocating the full cap at construction. A pool now starts at CacheConfig::initial_capacity and doubles as writes need room, up to the layer's cap; so a short session doesn't pay for a long one's worth of memory. Files - backends/mlx/runtime/MLXSequenceCache.h — Pool takes both bounds (initial_slots, max_slots) and allocates min of them. A write bounds its run against the cap, then grow_for doubles from the current size and clamps to the cap, since the last doubling can overshoot. MLXSequenceCache passes cfg.initial_capacity alongside the per-layer cap. - extension/llm/cache/cache.h — valid(cfg) now rejects a negative initial_capacity; zero is allowed and means "allocate nothing up front." - backends/mlx/test/mlx_sequence_cache_test.cpp — four growth cases. Testing Four cases added to the existing GTest suite (11 total), all passing: - a first write larger than the initial allocation grows instead of failing - a decode crossing the allocated boundary still reads back the full history (growth preserves written cells) - doubling stops once the run fits, a 16→32 doubling clamps to a cap of 20, and an initial above the cap clamps at construction - a zero initial capacity grows on first write; a negative one is rejected 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 e4f6748 commit 849f9d8

3 files changed

Lines changed: 146 additions & 11 deletions

File tree

backends/mlx/runtime/MLXSequenceCache.h

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
#pragma once
1010

11+
#include <algorithm>
1112
#include <optional>
1213
#include <stdexcept>
1314
#include <string>
@@ -31,16 +32,21 @@ namespace cache = ::executorch::extension::llm::cache;
3132
// layer asks for and how many runs a step produces.
3233
class Pool {
3334
public:
34-
Pool(int slots, int H, int D, ::mlx::core::Dtype dtype)
35+
// initial_slots above max_slots is clamped, not rejected: the config default
36+
// exceeds the cap of any smaller cache, so this is the normal path.
37+
Pool(int initial_slots, int max_slots, int H, int D, ::mlx::core::Dtype dtype)
3538
: dtype_(dtype),
36-
buf_(::mlx::core::zeros(::mlx::core::Shape{1, H, slots, D}, dtype)) {}
39+
max_slots_(max_slots),
40+
buf_(::mlx::core::zeros(
41+
::mlx::core::Shape{1, H, std::min(initial_slots, max_slots), D},
42+
dtype)) {}
3743

3844
// Place `update` at the run's physical start, casting to the storage dtype if
3945
// it differs.
4046
void write(const cache::Run& run, const Tensor& update, StreamOrDevice s) {
4147
const int H = static_cast<int>(buf_.shape(1));
4248
const int D = static_cast<int>(buf_.shape(3));
43-
if (run.start < 0 || run.start + run.len > slots()) {
49+
if (run.start < 0 || run.start + run.len > max_slots_) {
4450
throw std::runtime_error("Pool::write: run out of bounds");
4551
}
4652
if (static_cast<int>(update.shape(2)) != run.len) {
@@ -50,6 +56,7 @@ class Pool {
5056
static_cast<int>(update.shape(3)) != D) {
5157
throw std::runtime_error("Pool::write: K/V heads/dim mismatch");
5258
}
59+
maybe_grow(run.start + run.len, s);
5360
const Tensor u = update.dtype() == dtype_
5461
? update
5562
: ::mlx::core::astype(update, dtype_, s);
@@ -77,12 +84,36 @@ class Pool {
7784
s);
7885
}
7986

87+
// Slots currently allocated; grows toward max_slots on demand.
8088
int slots() const {
8189
return static_cast<int>(buf_.shape(2));
8290
}
8391

8492
private:
93+
// Make room for `needed` slots, growing only if the pool is short: double
94+
// until it fits, never past max_slots_. Cells keep their index, so growth is
95+
// a zero-pad on the cell axis.
96+
void maybe_grow(int needed, StreamOrDevice s) {
97+
const int cur = slots();
98+
if (needed <= cur) {
99+
return;
100+
}
101+
int next = std::max(cur, 1); // an empty pool has nothing to double
102+
while (next < needed) {
103+
next *= 2;
104+
}
105+
// The last doubling can overshoot; write() already bounds `needed` by
106+
// max_slots_, so clamping here cannot undershoot it.
107+
next = std::min(next, max_slots_);
108+
const int H = static_cast<int>(buf_.shape(1));
109+
const int D = static_cast<int>(buf_.shape(3));
110+
Tensor pad =
111+
::mlx::core::zeros(::mlx::core::Shape{1, H, next - cur, D}, dtype_);
112+
buf_ = ::mlx::core::concatenate(std::vector<Tensor>{buf_, pad}, 2, s);
113+
}
114+
85115
::mlx::core::Dtype dtype_;
116+
int max_slots_;
86117
Tensor buf_;
87118
};
88119

@@ -111,11 +142,13 @@ class MLXSequenceCache : public cache::SequenceCache, public MLXCache {
111142
throw std::runtime_error(
112143
"MLXSequenceCache: only Flat layers are supported so far");
113144
}
114-
// Flat retains all history, so the layer's pool is the full cap. A ring
115-
// layer will instead ask for window + max_write - 1 slots.
116-
const int slots = cfg.capacity;
117-
kpool_.emplace_back(slots, lc.n_kv_heads, lc.head_dim, dt);
118-
vpool_.emplace_back(slots, lc.n_kv_heads, lc.head_dim, dt);
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);
119152
}
120153
}
121154

backends/mlx/test/mlx_sequence_cache_test.cpp

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
#include <gtest/gtest.h>
2525

26+
#include <optional>
2627
#include <vector>
2728

2829
using namespace ::executorch::backends::mlx;
@@ -46,7 +47,8 @@ cache::CacheConfig flat_config(
4647
int n_layers,
4748
int n_kv_heads,
4849
int head_dim,
49-
int kv_dtype) {
50+
int kv_dtype,
51+
std::optional<int> initial_capacity = std::nullopt) {
5052
cache::CacheConfig cfg;
5153
cfg.capacity = capacity;
5254
cfg.n_layers = n_layers;
@@ -55,6 +57,9 @@ cache::CacheConfig flat_config(
5557
n_kv_heads,
5658
head_dim}};
5759
cfg.kv_dtype = kv_dtype;
60+
if (initial_capacity) {
61+
cfg.initial_capacity = *initial_capacity;
62+
}
5863
return cfg;
5964
}
6065

@@ -151,7 +156,7 @@ TEST_F(MLXSequenceCacheTest, StorageDtypeDiffersCastsOnWrite) {
151156
// mid-pool, and dropping the start would silently return the wrong cells.
152157
TEST_F(MLXSequenceCacheTest, PoolHonorsRunStart) {
153158
using namespace ::mlx::core;
154-
Pool p(/*slots=*/8, H, D, float16);
159+
Pool p(/*initial_slots=*/8, /*max_slots=*/8, H, D, float16);
155160
array x = randn(3, float16);
156161
p.write(cache::Run{/*start=*/2, /*len=*/3}, x, s);
157162

@@ -173,4 +178,99 @@ TEST_F(MLXSequenceCacheTest, PartialLayerListThrows) {
173178
EXPECT_ANY_THROW(MLXSequenceCache{cfg});
174179
}
175180

181+
// A step past the allocated slots grows the pool instead of failing, and the
182+
// result is the same window a fully-allocated pool would have returned.
183+
TEST_F(MLXSequenceCacheTest, GrowsPastInitialCapacity) {
184+
using namespace ::mlx::core;
185+
MLXSequenceCache c(flat_config(
186+
/*capacity=*/32,
187+
/*n_layers=*/1,
188+
H,
189+
D,
190+
static_cast<int>(ScalarType::Half),
191+
/*initial_capacity=*/2));
192+
193+
const int T0 = 5; // > initial_capacity
194+
array k0 = randn(T0, float16);
195+
array v0 = randn(T0, float16);
196+
AttendSpec spec0 = c.update_and_fetch(0, /*position=*/0, k0, v0, s);
197+
EXPECT_EQ(spec0.K.shape(2), T0);
198+
EXPECT_TRUE(allclose(spec0.K, k0, 0.0f));
199+
EXPECT_TRUE(allclose(spec0.V, v0, 0.0f));
200+
}
201+
202+
// Growth preserves cells already written: a decode crossing the allocated
203+
// boundary must still read back the full history.
204+
TEST_F(MLXSequenceCacheTest, GrowthPreservesExistingCells) {
205+
using namespace ::mlx::core;
206+
MLXSequenceCache c(flat_config(
207+
/*capacity=*/32,
208+
/*n_layers=*/1,
209+
H,
210+
D,
211+
static_cast<int>(ScalarType::Half),
212+
/*initial_capacity=*/2));
213+
214+
array k0 = randn(2, float16); // exactly fills the initial allocation
215+
array v0 = randn(2, float16);
216+
c.update_and_fetch(0, /*position=*/0, k0, v0, s);
217+
218+
array k1 = randn(1, float16); // crosses the boundary -> grows
219+
array v1 = randn(1, float16);
220+
AttendSpec spec1 = c.update_and_fetch(0, /*position=*/2, k1, v1, s);
221+
EXPECT_EQ(spec1.K.shape(2), 3);
222+
EXPECT_TRUE(
223+
allclose(spec1.K, concatenate(std::vector<array>{k0, k1}, 2, s), 0.0f));
224+
EXPECT_TRUE(
225+
allclose(spec1.V, concatenate(std::vector<array>{v0, v1}, 2, s), 0.0f));
226+
}
227+
228+
// Growth doubles until the run fits, and never allocates past max_slots --
229+
// including when the last doubling would overshoot it.
230+
TEST_F(MLXSequenceCacheTest, PoolDoublesAndClampsToMaxSlots) {
231+
using namespace ::mlx::core;
232+
Pool p(/*initial_slots=*/2, /*max_slots=*/32, H, D, float16);
233+
EXPECT_EQ(p.slots(), 2);
234+
p.write(cache::Run{0, 5}, randn(5, float16), s); // 2 -> 4 -> 8
235+
EXPECT_EQ(p.slots(), 8);
236+
237+
// 16 -> 32 overshoots a cap of 20, so it clamps.
238+
Pool q(/*initial_slots=*/16, /*max_slots=*/20, H, D, float16);
239+
q.write(cache::Run{0, 17}, randn(17, float16), s);
240+
EXPECT_EQ(q.slots(), 20);
241+
242+
// initial_slots above the cap is clamped at construction.
243+
Pool r(/*initial_slots=*/512, /*max_slots=*/4, H, D, float16);
244+
EXPECT_EQ(r.slots(), 4);
245+
}
246+
247+
// A pool that starts empty is allowed, and grows on the first write.
248+
TEST_F(MLXSequenceCacheTest, ZeroInitialCapacityGrowsOnFirstWrite) {
249+
using namespace ::mlx::core;
250+
MLXSequenceCache c(flat_config(
251+
/*capacity=*/32,
252+
/*n_layers=*/1,
253+
H,
254+
D,
255+
static_cast<int>(ScalarType::Half),
256+
/*initial_capacity=*/0));
257+
array k0 = randn(3, float16);
258+
array v0 = randn(3, float16);
259+
AttendSpec spec0 = c.update_and_fetch(0, /*position=*/0, k0, v0, s);
260+
EXPECT_TRUE(allclose(spec0.K, k0, 0.0f));
261+
}
262+
263+
// A negative initial_capacity is rejected rather than reaching MLX as a
264+
// negative dimension.
265+
TEST_F(MLXSequenceCacheTest, NegativeInitialCapacityThrows) {
266+
cache::CacheConfig cfg = flat_config(
267+
/*capacity=*/32,
268+
/*n_layers=*/1,
269+
H,
270+
D,
271+
static_cast<int>(ScalarType::Half),
272+
/*initial_capacity=*/-1);
273+
EXPECT_ANY_THROW(MLXSequenceCache{cfg});
274+
}
275+
176276
} // namespace

extension/llm/cache/cache.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ struct CacheConfig {
136136
// list that is neither size 1 nor n_layers reads past the end. Reported as a
137137
// bool rather than thrown, so each backend picks its own failure mode.
138138
inline bool valid(const CacheConfig& cfg) {
139-
return cfg.capacity > 0 && cfg.n_layers > 0 &&
139+
// initial_capacity may be 0 (allocate nothing up front) but not negative, and
140+
// may exceed capacity -- the byte layer clamps it.
141+
return cfg.capacity > 0 && cfg.n_layers > 0 && cfg.initial_capacity >= 0 &&
140142
(cfg.layers.size() == 1 ||
141143
cfg.layers.size() == static_cast<size_t>(cfg.n_layers));
142144
}

0 commit comments

Comments
 (0)