Skip to content

Commit db5f1ba

Browse files
authored
[MLX] Add options for clearing context (#20609)
Adds two per-model MLX backend runtime options to bound and reduce memory during long/multi-session serving. clear_cache_interval makes MLXBackend::execute() call mlx::core::clear_cache() every N forward calls (counter on the handle, guarded by the global mutex so no atomic is needed), and skip_mutable_buffer_init lets a multi-session load skip allocating the handle's default mutable-buffer (KV/recurrent state) copy, which is dead weight once mlx_mutable_state.h allocates per-session buffers — eliminating one full KV-cache copy. Both are delivered per-delegate via the existing LoadBackendOptionsMap/get_runtime_spec path and exposed through a shared backends/mlx/runtime/backend_options.h. Skipping is gated for safety: a new auto-generated for_each_tid walker (added to generate.py + the MLXLoader templates, covering all op nodes) powers init_chain_references_mutable_buffer(), and init() errors out cleanly if skip_mutable_buffer_init is set while the program's init chain references mutable buffers. mutable_state_bytes_per_session is rebased onto program metadata (with a per-tensor size clamp to avoid overflow from malformed payloads) so capacity accounting stays correct when default buffers are skipped. Finally, the gemma4_31b runner enables these in build_gemma_module: clear_cache_interval is hardcoded to 1, and skip_mutable_buffer_init is set automatically when max_sessions > 1.
1 parent 6386cef commit db5f1ba

8 files changed

Lines changed: 328 additions & 15 deletions

File tree

backends/mlx/runtime/MLXBackend.cpp

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
#include <mlx/mlx.h>
2121

22+
#include <executorch/backends/mlx/runtime/backend_options.h>
23+
2224
#include <cstring>
2325
#include <limits>
2426
#include <memory>
@@ -169,6 +171,12 @@ struct MLXHandle {
169171
Interpreter interpreter;
170172
::mlx::core::Stream stream; // Dedicated GPU stream for this handle
171173

174+
// Periodically call mlx::core::clear_cache() every N execute() calls to
175+
// release MLX's cached buffer pool. 0 disables. Counter is non-atomic: it is
176+
// only touched inside mlx_global_mutex() (see execute()).
177+
int clear_cache_interval_{0};
178+
uint64_t execute_count_{0};
179+
172180
// Keep the constant buffers alive for zero-copy constants
173181
// Each FreeableBuffer must outlive the MLX arrays that reference it
174182
std::vector<FreeableBuffer> constant_buffers;
@@ -211,6 +219,13 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
211219
try {
212220
new (handle) MLXHandle();
213221

222+
// Per-model clear-cache cadence (optional runtime spec, keyed per
223+
// delegate)
224+
if (auto spec = context.get_runtime_spec<int>(kClearCacheIntervalKey);
225+
spec.ok() && spec.get() > 0) {
226+
handle->clear_cache_interval_ = spec.get();
227+
}
228+
214229
if (!processed || !processed->data() || processed->size() == 0) {
215230
throw std::runtime_error("init: null or empty delegate payload");
216231
}
@@ -254,8 +269,42 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
254269
processed->Free();
255270
processed = nullptr;
256271

257-
// Load mutable buffers (e.g., KV cache)
258-
load_mutable_buffers(handle->program, handle->mutable_buffers);
272+
// Load mutable buffers (e.g., KV cache). When skip_mutable_buffer_init is
273+
// set (multi-session, where mlx_mutable_state.h allocates per-session
274+
// buffers), avoid keeping a dead default copy on the handle. This is only
275+
// safe if the init chain does not reference mutable buffers — otherwise
276+
// it would run against an empty store, so we error out clearly.
277+
bool skip_mutable_buffer_init = false;
278+
if (auto spec = context.get_runtime_spec<bool>(kSkipMutableBufferInitKey);
279+
spec.ok()) {
280+
skip_mutable_buffer_init = spec.get();
281+
}
282+
// skip_mutable_buffer_init is only safe under a multi-session owner: the
283+
// per-session manager allocates the buffers and execute() rebinds to
284+
// them. Without an active load scope no handle is registered, no
285+
// per-session buffers are ever created, and execute() would run against
286+
// empty buffers. Reject the misuse loudly here instead of failing later.
287+
if (skip_mutable_buffer_init && !mutable_state_load_scope_active()) {
288+
ET_LOG(
289+
Error,
290+
"skip_mutable_buffer_init set without an active multi-session load "
291+
"scope; mutable buffers would never be allocated");
292+
throw std::runtime_error(
293+
"skip_mutable_buffer_init requires an active multi-session owner");
294+
}
295+
if (skip_mutable_buffer_init &&
296+
init_chain_references_mutable_buffer(handle->program)) {
297+
ET_LOG(
298+
Error,
299+
"skip_mutable_buffer_init set but the init chain references "
300+
"mutable buffers; cannot skip default mutable-buffer init");
301+
throw std::runtime_error(
302+
"skip_mutable_buffer_init incompatible with init chain that "
303+
"references mutable buffers");
304+
}
305+
if (!skip_mutable_buffer_init) {
306+
load_mutable_buffers(handle->program, handle->mutable_buffers);
307+
}
259308

260309
// Bind execution state (reused across execute() calls)
261310
handle->state.bind(
@@ -433,6 +482,16 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
433482

434483
h->state.reset(); // Release temp GPU buffers back to MLX cache
435484

485+
// Periodically release MLX's cached buffer pool. The counter increment
486+
// and clear_cache() run under the global mutex so they can't race another
487+
// handle's in-flight submission, and the counter needs no atomic.
488+
if (h->clear_cache_interval_ > 0) {
489+
std::lock_guard<std::mutex> lock(mlx_global_mutex());
490+
if ((++h->execute_count_ % h->clear_cache_interval_) == 0) {
491+
::mlx::core::clear_cache();
492+
}
493+
}
494+
436495
return Error::Ok;
437496
} catch (const std::exception& e) {
438497
ET_LOG(Error, "MLX execute failed: %s", e.what());
@@ -455,7 +514,7 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
455514

456515
namespace {
457516
auto cls = MLXBackend();
458-
Backend backend{"MLXBackend", &cls};
517+
Backend backend{kMLXBackendId, &cls};
459518
static auto success_with_compiler = register_backend(backend);
460519
} // namespace
461520

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
// Shared option keys for the MLX backend. Included by the backend itself
10+
// (to read the per-model runtime spec) and by callers/runners (to set it via
11+
// a LoadBackendOptionsMap), keeping the string literals in one place.
12+
13+
#pragma once
14+
15+
namespace executorch {
16+
namespace backends {
17+
namespace mlx {
18+
19+
// Backend id under which the MLX backend registers (see MLXBackend.cpp).
20+
inline constexpr char kMLXBackendId[] = "MLXBackend";
21+
22+
// Per-model runtime-spec key. Value N means: call mlx::core::clear_cache()
23+
// every N execute() calls to release MLX's cached buffer pool; 0/unset
24+
// disables.
25+
//
26+
// NOTE on granularity: MLX's buffer cache is a process-global singleton, so the
27+
// flush is global even though this key is read per delegate handle. The counter
28+
// is per-handle, so with a single MLX handle (the common case — gemma's
29+
// prefill/decode share one "forward" method) the cadence is exactly "every N
30+
// forwards"; if a process loads multiple MLX handles, the effective cadence is
31+
// the aggregate of their executes and any handle's flush frees the shared pool
32+
// for all. This bounds resident-*average*: between flushes the cache can still
33+
// grow to MLX's default ceiling, and each flush is followed by a cold-cache
34+
// realloc. A future set_cache_limit-style key could complement this by bounding
35+
// peak footprint continuously.
36+
inline constexpr char kClearCacheIntervalKey[] = "clear_cache_interval";
37+
38+
// Per-model runtime-spec key (bool). When true, the handle does not allocate
39+
// its own default mutable buffers at init() — per-session buffers are managed
40+
// by mlx_mutable_state.h instead. Only valid for multi-session loads, and only
41+
// when the program's init chain does not reference mutable buffers (init()
42+
// errors otherwise). Saves one full mutable-buffer (KV-cache) copy per handle.
43+
inline constexpr char kSkipMutableBufferInitKey[] = "skip_mutable_buffer_init";
44+
45+
} // namespace mlx
46+
} // namespace backends
47+
} // namespace executorch

backends/mlx/runtime/mlx_mutable_state.cpp

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,52 @@ int64_t mutable_state_bytes_per_session(MutableStateContext ctx) {
136136
return 0;
137137
}
138138
int64_t total = 0;
139+
// Per-session mutable buffers are allocated from program metadata
140+
// (mutable_buffer_map + tensor_meta), independent of whether the handle kept
141+
// a default copy. Compute the estimate from metadata so it stays correct even
142+
// when default mutable-buffer init was skipped (skip_mutable_buffer_init).
139143
for (const auto& kv : it->second.handles) {
140-
const MutableBufferData* bufs = kv.second.default_buffers;
141-
if (bufs == nullptr) {
144+
const MLXProgram* program = kv.second.program;
145+
if (program == nullptr) {
142146
continue;
143147
}
144-
for (const auto& t : bufs->tensors) {
145-
if (t.has_value()) {
146-
total += static_cast<int64_t>(t->nbytes());
148+
for (const auto& slot : program->mutable_buffer_map) {
149+
if (slot.slot_type != SlotType::TensorSlot ||
150+
slot.idx >= program->tensor_meta.size() ||
151+
!program->tensor_meta[slot.idx].has_value()) {
152+
continue;
147153
}
154+
const auto& meta = *program->tensor_meta[slot.idx];
155+
// Sum sizes from metadata, clamping each tensor to kMaxAllocationBytes so
156+
// malformed (oversized) shapes in an untrusted program can't overflow the
157+
// accumulator. Real allocations are independently bounded by
158+
// check_allocation_bounded at load_mutable_buffers.
159+
uint64_t bytes = static_cast<uint64_t>(
160+
::mlx::core::size_of(resolve_dtype(meta.scalar_type)));
161+
bool dynamic = false;
162+
for (const auto& dim : meta.shape) {
163+
if (dim.value < 0) {
164+
dynamic = true;
165+
break;
166+
}
167+
const uint64_t d = static_cast<uint64_t>(dim.value);
168+
if (d == 0) {
169+
bytes = 0;
170+
break;
171+
}
172+
if (bytes > kMaxAllocationBytes / d) {
173+
bytes = kMaxAllocationBytes; // clamp; avoids overflow
174+
} else {
175+
bytes *= d;
176+
}
177+
}
178+
if (dynamic) {
179+
continue;
180+
}
181+
if (bytes > kMaxAllocationBytes) {
182+
bytes = kMaxAllocationBytes;
183+
}
184+
total += static_cast<int64_t>(bytes);
148185
}
149186
}
150187
return total;
@@ -217,6 +254,10 @@ void mutable_state_note_handle(
217254
handle_ctx()[handle] = tl_loading_ctx;
218255
}
219256

257+
bool mutable_state_load_scope_active() {
258+
return tl_loading_ctx != kInvalidMutableContext;
259+
}
260+
220261
void mutable_state_forget_handle(const void* handle) {
221262
std::lock_guard<std::mutex> g(registry_mutex());
222263
auto hit = handle_ctx().find(handle);

backends/mlx/runtime/mlx_mutable_state.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ void mutable_state_note_handle(
195195

196196
void mutable_state_forget_handle(const void* handle);
197197

198+
// True if a multi-session load scope (with_load_scope) is active on the current
199+
// thread. The MLXBackend uses this to reject skip_mutable_buffer_init when no
200+
// owner is active for the load — without an owner, no per-session buffers are
201+
// ever allocated and execute() would run against empty mutable buffers.
202+
bool mutable_state_load_scope_active();
203+
198204
::executorch::runtime::Error mutable_state_rebind_for_execute(
199205
const void* handle,
200206
ExecutionState& state);

backends/mlx/serialization/MLXLoader.h.tmpl

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ struct Instruction {
118118
}
119119
};
120120

121+
// =============================================================================
122+
// for_each_tid - invokes cb(Tid) for every tensor id referenced by an
123+
// instruction's node (AUTO-GENERATED from schema.fbs).
124+
//
125+
// NOTE: nested chain-index fields (ScanNode::body_chain_idx,
126+
// IfNode::then_chain_idx/else_chain_idx) are NOT followed here; callers that
127+
// need transitive coverage must recurse into those chains themselves.
128+
// =============================================================================
129+
130+
template <typename F>
131+
inline void for_each_tid(const Instruction& instr, F&& cb) {
132+
switch (instr.op) {
133+
{{FOR_EACH_TID_CASES}}
134+
default:
135+
throw std::runtime_error(
136+
std::string("for_each_tid: unhandled op ") + op_name(instr.op));
137+
}
138+
}
139+
121140
// =============================================================================
122141
// SlotVariant for I/O mapping
123142
// =============================================================================
@@ -194,6 +213,65 @@ struct MLXProgram {
194213
}
195214
};
196215

216+
// =============================================================================
217+
// init_chain_references_mutable_buffer - returns true if the program's init
218+
// chain references any mutable-buffer Tid, following nested SCAN/IF chains.
219+
//
220+
// Used to decide whether the handle's default mutable-buffer initialization can
221+
// be safely skipped (e.g. when buffers are managed per-session). If the init
222+
// chain reads/writes mutable buffers, skipping would be unsafe.
223+
// =============================================================================
224+
inline bool init_chain_references_mutable_buffer(const MLXProgram& program) {
225+
if (program.init_chain_idx < 0 || program.num_mutable_buffer_tensors == 0) {
226+
return false;
227+
}
228+
// Tid layout: Constant -> Input -> Output -> MutableBuffer -> Temp.
229+
const uint32_t output_end = program.num_constant_tensors +
230+
program.num_input_tensors + program.num_output_tensors;
231+
const uint32_t mutable_buffer_end =
232+
output_end + program.num_mutable_buffer_tensors;
233+
auto is_mutable_buffer = [&](Tid t) {
234+
return t.idx >= output_end && t.idx < mutable_buffer_end;
235+
};
236+
237+
const size_t num_chains = program.instruction_chains.size();
238+
std::vector<bool> visited(num_chains, false);
239+
std::vector<uint32_t> worklist;
240+
worklist.push_back(static_cast<uint32_t>(program.init_chain_idx));
241+
242+
while (!worklist.empty()) {
243+
const uint32_t chain_idx = worklist.back();
244+
worklist.pop_back();
245+
if (chain_idx >= num_chains || visited[chain_idx]) {
246+
continue;
247+
}
248+
visited[chain_idx] = true;
249+
for (const auto& instr : program.instruction_chains[chain_idx]) {
250+
bool hit = false;
251+
for_each_tid(instr, [&](Tid t) {
252+
if (is_mutable_buffer(t)) {
253+
hit = true;
254+
}
255+
});
256+
if (hit) {
257+
return true;
258+
}
259+
// Follow nested chains (not covered by for_each_tid).
260+
if (instr.op == OpCode::SCAN) {
261+
const auto& n = std::get<ScanNode>(instr.node);
262+
if (n.body_chain_idx >= 0) {
263+
worklist.push_back(static_cast<uint32_t>(n.body_chain_idx));
264+
}
265+
} else if (instr.op == OpCode::IF) {
266+
const auto& n = std::get<IfNode>(instr.node);
267+
worklist.push_back(n.then_chain_idx);
268+
worklist.push_back(n.else_chain_idx);
269+
}
270+
}
271+
}
272+
return false;
273+
}
274+
197275
// =============================================================================
198276
// FlatBuffer loading functions
199277
// =============================================================================

backends/mlx/serialization/generate.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,16 +996,59 @@ def generate_cpp_loader_h(schema: FBSSchema) -> str:
996996
comma = "," if i < len(op_nodes) - 1 else ""
997997
variant_lines.append(f" {table.name}{comma}")
998998

999+
for_each_tid_lines = []
1000+
for table in op_nodes:
1001+
for_each_tid_lines.extend(_generate_for_each_tid_case(table))
1002+
9991003
# Read template and fill placeholders
10001004
header = "\n".join(_file_header("//")) + "\n//\n"
10011005
tmpl = LOADER_H_TMPL.read_text()
10021006
result = tmpl.replace("{{OP_NODE_STRUCTS}}", "\n".join(struct_lines))
10031007
result = result.replace("{{OPCODE_ENUM_VALUES}}", "\n".join(enum_lines))
10041008
result = result.replace("{{OP_NAME_CASES}}", "\n".join(name_lines))
10051009
result = result.replace("{{NODE_VARIANT_TYPES}}", "\n".join(variant_lines))
1010+
result = result.replace("{{FOR_EACH_TID_CASES}}", "\n".join(for_each_tid_lines))
10061011
return header + result
10071012

10081013

1014+
# for_each_tid emitters: invoke cb(Tid) for each Tid-bearing field. Field kinds
1015+
# that carry no Tid (vid, int_or_vid, scalars, strings, int lists) emit nothing.
1016+
def _emit_cpp_for_each_tid(kind: str, name: str) -> List[str]:
1017+
"""Emit for_each_tid callback lines for a field kind ([] if it has no Tid)."""
1018+
if kind == "tid":
1019+
return [f" cb(n.{name});"]
1020+
if kind == "optional_tid":
1021+
return [f" if (n.{name}.has_value()) cb(*n.{name});"]
1022+
if kind == "list_tid":
1023+
return [f" for (const auto& tid_elem : n.{name}) cb(tid_elem);"]
1024+
if kind == "vid_or_tid":
1025+
return [f" if (!n.{name}.is_vid) cb(n.{name}.tid);"]
1026+
if kind == "int_or_vid_or_tid":
1027+
return [f" if (n.{name}.kind == 2) cb(n.{name}.tid);"]
1028+
return []
1029+
1030+
1031+
def _generate_for_each_tid_case(table: FBSTable) -> List[str]:
1032+
"""Generate a switch case for for_each_tid over one op node."""
1033+
class_name = table.name
1034+
op_code = _table_name_to_opcode(class_name)
1035+
1036+
body: List[str] = []
1037+
for fld in table.fields:
1038+
if fld.name.endswith("_is_set"):
1039+
continue
1040+
kind = _get_field_kind(fld, table)
1041+
body.extend(_emit_cpp_for_each_tid(kind, fld.name))
1042+
1043+
lines = [f" case OpCode::{op_code}: {{"]
1044+
if body:
1045+
lines.append(f" const auto& n = std::get<{class_name}>(instr.node);")
1046+
lines.extend(body)
1047+
lines.append(" break;")
1048+
lines.append(" }")
1049+
return lines
1050+
1051+
10091052
def _is_interned_str(table, field_name) -> bool:
10101053
"""Whether a string field should be loaded as an interned shared_ptr.
10111054

0 commit comments

Comments
 (0)