Skip to content

Commit 60ae401

Browse files
[MLX] Place the KV pool's arrays on the stream it was given (pytorch#22230)
Pool's constructor allocated buf_ with a zeros() that took no stream, so it fell back to default_stream(): the calling thread's default, resolved at call time and stamped into the array's primitive. The constructor cannot be fixed by passing it a stream, because none exists yet. The stream belongs to the delegate handle, and the cache is built and registered under a key before init() constructs the handle that looks it up. So the constructor now allocates nothing: Pool holds its geometry and materialises buf_ on the first maybe_grow, on the stream that write supplied. That leaves one place in the class that creates arrays, and it cannot run without a stream. slots() now reports 0 until the first write, and read() on an untouched pool throws instead of returning zeros. PoolDoublesAndClampsToMaxSlots asserts the initial and clamped sizes after a write rather than after construction.
1 parent dce246e commit 60ae401

2 files changed

Lines changed: 40 additions & 34 deletions

File tree

‎backends/mlx/runtime/MLXPool.h‎

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#pragma once
1010

1111
#include <algorithm>
12+
#include <optional>
1213
#include <stdexcept>
1314
#include <vector>
1415

@@ -25,38 +26,38 @@ namespace mlx {
2526
// where in them a step lands.
2627
class Pool {
2728
public:
28-
// initial_slots above max_slots is clamped, not rejected: the config default
29-
// exceeds the cap of any smaller cache, so this is the normal path.
29+
// Holds its geometry but allocates nothing: every array belongs to the
30+
// stream its work runs on, and the first stream reaches the pool with the
31+
// first write. initial_slots above max_slots is clamped, not rejected: the
32+
// config default exceeds the cap of any smaller cache.
3033
Pool(int initial_slots, int max_slots, int H, int D, ::mlx::core::Dtype dtype)
3134
: dtype_(dtype),
3235
max_slots_(max_slots),
33-
buf_(::mlx::core::zeros(
34-
::mlx::core::Shape{1, H, std::min(initial_slots, max_slots), D},
35-
dtype)) {}
36+
initial_slots_(std::min(initial_slots, max_slots)),
37+
H_(H),
38+
D_(D) {}
3639

3740
// Place `update` at slot `start`, casting to the storage dtype if it differs.
3841
void write(int start, int len, const Tensor& update, StreamOrDevice s) {
39-
const int H = static_cast<int>(buf_.shape(1));
40-
const int D = static_cast<int>(buf_.shape(3));
4142
if (start < 0 || start + len > max_slots_) {
4243
throw std::runtime_error("Pool::write: run out of bounds");
4344
}
4445
if (static_cast<int>(update.shape(2)) != len) {
4546
throw std::runtime_error("Pool::write: update length != run length");
4647
}
47-
if (static_cast<int>(update.shape(1)) != H ||
48-
static_cast<int>(update.shape(3)) != D) {
48+
if (static_cast<int>(update.shape(1)) != H_ ||
49+
static_cast<int>(update.shape(3)) != D_) {
4950
throw std::runtime_error("Pool::write: K/V heads/dim mismatch");
5051
}
5152
maybe_grow(start + len, s);
5253
const Tensor u = update.dtype() == dtype_
5354
? update
5455
: ::mlx::core::astype(update, dtype_, s);
5556
buf_ = ::mlx::core::slice_update(
56-
buf_,
57+
*buf_,
5758
u,
5859
::mlx::core::Shape{0, 0, start, 0},
59-
::mlx::core::Shape{1, H, start + len, D},
60+
::mlx::core::Shape{1, H_, start + len, D_},
6061
s);
6162
}
6263

@@ -67,15 +68,13 @@ class Pool {
6768
const std::vector<int32_t>& cells,
6869
const Tensor& update,
6970
StreamOrDevice s) {
70-
const int H = static_cast<int>(buf_.shape(1));
71-
const int D = static_cast<int>(buf_.shape(3));
7271
const int T = static_cast<int>(cells.size());
7372
if (static_cast<int>(update.shape(2)) != T) {
7473
throw std::runtime_error(
7574
"Pool::write_cells: update length != cell count");
7675
}
77-
if (static_cast<int>(update.shape(1)) != H ||
78-
static_cast<int>(update.shape(3)) != D) {
76+
if (static_cast<int>(update.shape(1)) != H_ ||
77+
static_cast<int>(update.shape(3)) != D_) {
7978
throw std::runtime_error("Pool::write_cells: K/V heads/dim mismatch");
8079
}
8180
int high = 0;
@@ -92,7 +91,7 @@ class Pool {
9291
// put_along_axis broadcasts the one index per token across heads and
9392
// head_dim.
9493
buf_ = ::mlx::core::put_along_axis(
95-
buf_,
94+
*buf_,
9695
::mlx::core::array(
9796
cells.data(), ::mlx::core::Shape{1, 1, T, 1}, ::mlx::core::int32),
9897
u,
@@ -103,29 +102,33 @@ class Pool {
103102
// Slots [start, start+len). A ring read starts mid-pool, so the start matters
104103
// here as much as it does for a write.
105104
Tensor read(int start, int len, StreamOrDevice s) const {
106-
const int H = static_cast<int>(buf_.shape(1));
107-
const int D = static_cast<int>(buf_.shape(3));
108-
if (start < 0 || start + len > slots()) {
105+
if (!buf_ || start < 0 || start + len > slots()) {
109106
throw std::runtime_error("Pool::read: run out of bounds");
110107
}
111108
return ::mlx::core::slice(
112-
buf_,
109+
*buf_,
113110
::mlx::core::Shape{0, 0, start, 0},
114-
::mlx::core::Shape{1, H, start + len, D},
111+
::mlx::core::Shape{1, H_, start + len, D_},
115112
::mlx::core::Shape{1, 1, 1, 1},
116113
s);
117114
}
118115

119-
// Slots currently allocated; grows toward max_slots on demand.
116+
// Slots currently allocated; 0 until the first write, then grows toward
117+
// max_slots on demand.
120118
int slots() const {
121-
return static_cast<int>(buf_.shape(2));
119+
return buf_ ? static_cast<int>(buf_->shape(2)) : 0;
122120
}
123121

124122
private:
125-
// Make room for `needed` slots, growing only if the pool is short: double
126-
// until it fits, never past max_slots_. Cells keep their index, so growth is
127-
// a zero-pad on the cell axis.
123+
// Make room for `needed` slots, allocating on first use and thereafter
124+
// growing only if the pool is short: double until it fits, never past
125+
// max_slots_. Cells keep their index, so growth is a zero-pad on the cell
126+
// axis. Every array here is placed on `s`, the stream the step runs on.
128127
void maybe_grow(int needed, StreamOrDevice s) {
128+
if (!buf_) {
129+
buf_ = ::mlx::core::zeros(
130+
::mlx::core::Shape{1, H_, initial_slots_, D_}, dtype_, s);
131+
}
129132
const int cur = slots();
130133
if (needed <= cur) {
131134
return;
@@ -137,16 +140,18 @@ class Pool {
137140
// The last doubling can overshoot; write() already bounds `needed` by
138141
// max_slots_, so clamping here cannot undershoot it.
139142
next = std::min(next, max_slots_);
140-
const int H = static_cast<int>(buf_.shape(1));
141-
const int D = static_cast<int>(buf_.shape(3));
142-
Tensor pad =
143-
::mlx::core::zeros(::mlx::core::Shape{1, H, next - cur, D}, dtype_);
144-
buf_ = ::mlx::core::concatenate(std::vector<Tensor>{buf_, pad}, 2, s);
143+
Tensor pad = ::mlx::core::zeros(
144+
::mlx::core::Shape{1, H_, next - cur, D_}, dtype_, s);
145+
buf_ = ::mlx::core::concatenate(std::vector<Tensor>{*buf_, pad}, 2, s);
145146
}
146147

147148
::mlx::core::Dtype dtype_;
148149
int max_slots_;
149-
Tensor buf_;
150+
int initial_slots_;
151+
int H_;
152+
int D_;
153+
// Absent until the first write hands the pool a stream to allocate on.
154+
std::optional<Tensor> buf_;
150155
};
151156

152157
} // namespace mlx

‎backends/mlx/test/mlx_sequence_cache_test.cpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ TEST_F(MLXSequenceCacheTest, GrowthPreservesExistingCells) {
241241
TEST_F(MLXSequenceCacheTest, PoolDoublesAndClampsToMaxSlots) {
242242
using namespace ::mlx::core;
243243
Pool p(/*initial_slots=*/2, /*max_slots=*/32, H, D, float16);
244-
EXPECT_EQ(p.slots(), 2);
244+
EXPECT_EQ(p.slots(), 0) << "a pool holds no slots until its first write";
245245
p.write(0, 5, randn(5, float16), s); // 2 -> 4 -> 8
246246
EXPECT_EQ(p.slots(), 8);
247247

@@ -250,8 +250,9 @@ TEST_F(MLXSequenceCacheTest, PoolDoublesAndClampsToMaxSlots) {
250250
q.write(0, 17, randn(17, float16), s);
251251
EXPECT_EQ(q.slots(), 20);
252252

253-
// initial_slots above the cap is clamped at construction.
253+
// initial_slots above the cap is clamped when the pool allocates.
254254
Pool r(/*initial_slots=*/512, /*max_slots=*/4, H, D, float16);
255+
r.write(0, 1, randn(1, float16), s);
255256
EXPECT_EQ(r.slots(), 4);
256257
}
257258

0 commit comments

Comments
 (0)