Skip to content

Commit aa47f06

Browse files
committed
MLX: bound the lazy graph so a method's peak memory is not its whole graph
MLX is lazy: Interpreter::dispatch only builds graph nodes, and nothing is materialized until MLXBackend::execute calls async_eval on the method outputs. For a long instruction chain that means every intermediate in the method is live at the same instant. Whisper-small encode is 495 instructions built before a single byte is evaluated. Measured with mlx::core::get_{active,peak,cache}_memory on macOS: peak 1105.6 MB against 94.8 MB of steady-state active memory. On iOS that peak lands in the app's footprint and is what makes the model unusable there (#22513). Evaluate the live per-execution tensors once the intermediates produced since the last barrier exceed a byte budget. Each barrier costs a GPU sync, so the cost tracks the NUMBER of barriers; budgeting bytes rather than counting instructions puts them only in the methods that actually allocate. Whisper-small at 512 MB takes 12 barriers in encode and 0 in decode. iPhone 16, whisper-small int8 through the full pipeline, medians of interleaved rounds: peak MB peak-loaded pipeline ms encode decode x10 no barrier 1194.4 763.7 885.2 463.2 472.6 byte budget 512MB 692.8 261.0 831.3 420.5 410.4 1.72x lower peak, 2.9x lower execute-phase footprint, and 1.06x faster than the unbounded path. macOS, medians of 4 interleaved rounds x 10 executions, round 1 discarded: peak MB (off -> 512) speed vs off small encode 1105.6 -> 350.1 1.05x small decode 258.0 -> 258.0 0.97x tiny encode 550.4 -> 343.2 1.07x tiny decode 61.8 -> 61.8 0.93x SmolLM2 fwd 1196.5 -> 496.8 1.00x Outputs bit-identical to the unbounded path on whisper tiny/base/small x {encode, decode} and on two MLX LLMs. ET_MLX_EVAL_BUDGET_MB overrides the budget; 0 restores the previous behaviour.
1 parent 7863536 commit aa47f06

1 file changed

Lines changed: 63 additions & 0 deletions

File tree

‎backends/mlx/runtime/MLXInterpreter.h‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
#include <mlx/mlx.h>
2020
#include <mlx/ops.h>
2121

22+
#include <algorithm>
23+
#include <cstdlib>
24+
2225
namespace executorch {
2326
namespace backends {
2427
namespace mlx {
@@ -1933,6 +1936,19 @@ class Interpreter {
19331936
run_chain(prog, prog.main_chain_idx, st, stream);
19341937
}
19351938

1939+
// Bytes of pending intermediates allowed to accumulate before a forced eval.
1940+
// 0 disables the barrier entirely (the pre-#22513 behaviour).
1941+
static size_t eval_budget_bytes() {
1942+
static const size_t bytes = [] {
1943+
const char* e = std::getenv("ET_MLX_EVAL_BUDGET_MB");
1944+
size_t mb = e == nullptr
1945+
? 512u
1946+
: static_cast<size_t>(std::strtoul(e, nullptr, 10));
1947+
return mb * 1024u * 1024u;
1948+
}();
1949+
return bytes;
1950+
}
1951+
19361952
void run_chain(
19371953
const MLXProgram& prog,
19381954
uint32_t chain_idx,
@@ -1945,6 +1961,27 @@ class Interpreter {
19451961
std::to_string(prog.instruction_chains.size()) + ")");
19461962
}
19471963
const auto& chain = prog.instruction_chains[chain_idx];
1964+
// MLX is lazy: dispatch() only builds graph nodes, and nothing is
1965+
// materialized until MLXBackend::execute calls async_eval on the outputs.
1966+
// For a long chain that means every intermediate in the method is live at
1967+
// the same time. Whisper-small's 495-instruction encode peaks at 1105 MB of
1968+
// MLX allocation against 95 MB of steady-state active memory, which is what
1969+
// makes the model unusable on an iPhone (pytorch/executorch#22513).
1970+
//
1971+
// Bound it by evaluating once the intermediates produced since the last
1972+
// barrier exceed a byte budget. Each barrier costs a GPU sync, so the cost
1973+
// tracks the NUMBER of barriers, and the budget is on bytes rather than an
1974+
// instruction count so that only methods which actually allocate get any.
1975+
// Whisper-small at 512 MB takes 12 barriers in encode and 0 in decode,
1976+
// where an every-32-instruction rule took 15 and 22 -- and those 22 bought
1977+
// 50 MB on a method that peaks at 258 MB while costing 21% on an iPhone 16.
1978+
//
1979+
// Per instruction we add the largest tensor it touches, which tracks the
1980+
// size of what it just produced without needing to know which tid is the
1981+
// output. Evaluating early does not change results (verified
1982+
// bit-identical).
1983+
const size_t eval_budget = eval_budget_bytes();
1984+
size_t pending_bytes = 0;
19481985
size_t idx = 0;
19491986
for (const auto& instr : chain) {
19501987
st.begin_op(idx, op_name(instr.op));
@@ -1957,6 +1994,32 @@ class Interpreter {
19571994
}
19581995
st.end_op();
19591996
++idx;
1997+
1998+
if (eval_budget != 0) {
1999+
size_t widest = 0;
2000+
for_each_tid(instr, [&](Tid id) {
2001+
if (id.idx >= st.num_constants && !st.is_mutable_buffer(id)) {
2002+
uint32_t slot = st.tensor_index(id);
2003+
if (slot < st.tensors.size() && st.tensors[slot].has_value()) {
2004+
widest = std::max(widest, st.tensors[slot]->nbytes());
2005+
}
2006+
}
2007+
});
2008+
pending_bytes += widest;
2009+
if (pending_bytes >= eval_budget) {
2010+
std::vector<::mlx::core::array> live;
2011+
live.reserve(st.tensors.size());
2012+
for (auto& t : st.tensors) {
2013+
if (t.has_value()) {
2014+
live.push_back(*t);
2015+
}
2016+
}
2017+
if (!live.empty()) {
2018+
::mlx::core::eval(live);
2019+
}
2020+
pending_bytes = 0;
2021+
}
2022+
}
19602023
}
19612024
}
19622025

0 commit comments

Comments
 (0)