Skip to content

MLX: bound the lazy graph so a method's peak memory is not its whole graph - #22932

Open
msluszniak wants to merge 5 commits into
pytorch:mainfrom
msluszniak:ms/mlx-bound-lazy-graph-memory
Open

msluszniak wants to merge 5 commits into
pytorch:mainfrom
msluszniak:ms/mlx-bound-lazy-graph-memory

Conversation

@msluszniak

@msluszniak msluszniak commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Fixes the root cause of #22513.

The problem

MLX is lazy. Interpreter::dispatch only builds graph nodes; nothing is materialized until MLXBackend::execute calls async_eval on the method outputs (MLXBackend.cpp:557). 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 on macOS with mlx::core::get_{active,peak,cache}_memory():

whisper-small encode
MLX peak 1105.6 MB
MLX active, steady 94.8 MB
MLX cache 262.3 MB (at the existing 256 MB cap)

On iOS that peak lands in the app's footprint, which is what makes the model unusable there.

The fix

Evaluate the live per-execution tensors once the intermediates produced since the last barrier exceed a byte threshold. Off by default; opt in per model with the eval_threshold_bytes runtime option.

Each barrier costs a GPU sync, so the cost tracks the number of barriers, not the interval. Budgeting bytes rather than counting instructions puts barriers only in the methods that actually allocate: whisper-small at 512 MB takes 12 barriers in encode and 0 in decode. An every-32-instruction rule took 15 and 22, and those 22 bought 50 MB on a method that peaks at 258 MB while costing 21% on device.

iPhone 16, whisper-small int8, full pipeline, medians of interleaved rounds:

arm peak MB peak-loaded pipeline ms encode decode x10
no barrier 1194.4 763.7 885.2 463.2 472.6
eval_threshold_bytes 512 MB 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:

model method peak off -> 512 MB speed vs off
whisper-small encode 1105.6 -> 350.1 MB 1.05x
whisper-small decode 258.0 -> 258.0 MB 0.97x
whisper-tiny encode 550.4 -> 343.2 MB 1.07x
whisper-tiny decode 61.8 -> 61.8 MB 0.93x
SmolLM2-135M forward 1196.5 -> 496.8 MB 1.00x

Outputs are bit-identical to the unbounded path on whisper tiny/base/small x {encode, decode} and on two MLX LLMs.

The threshold is the eval_threshold_bytes runtime option, read per delegate handle alongside clear_cache_interval and validated at load. It defaults to 0, which disables the mechanism entirely: no traversal, no nbytes() queries, no accumulation, no evaluation-root collection. The measurements above are at 512 MB.

What this is not

Two other candidates were measured and ruled out:

  • Buffer cache. Already capped by set_cache_limit(256MB) at init. Forcing ET_MLX_CACHE_LIMIT_MB=16 moves cache 262 -> 103 MB and leaves peak bit-identical at 1105.6 MB.
  • Slot retention. ExecutionState::tensors holds only 7 slots for those 495 instructions; the AOT side already plans and reuses them. A last-use release built on for_each_tid freed 5 slots and moved peak by 0 MB. The arrays are pinned by the pending graph, not the slot table.

cc @metascroy

@pytorch-bot

pytorch-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22932

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 107 Cancelled Jobs, 2 Unrelated Failures, 12 Unclassified Failures

As of commit cdb39bd with merge base 7863536 (image):

NEW FAILURE - The following job has failed:

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

CANCELLED JOBS - The following jobs were cancelled. Please retry:

FLAKY - The following jobs failed but were likely due to flakiness present on trunk:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 18, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

msluszniak added a commit to software-mansion-labs/executorch that referenced this pull request Sep 18, 2026
…graph

Backport of pytorch/executorch#22932.

MLX is lazy: dispatch() only builds graph nodes and nothing is materialized
until MLXBackend::execute calls async_eval on the outputs, so every intermediate
in a method is live at once. Whisper-small encode (495 instructions) peaks at
1105.6 MB against 94.8 MB steady active; on iOS that peak lands in the app's
footprint.

Evaluating every N instructions bounds the pending graph: tiny 550 -> 166 MB,
base 1071 -> 230 MB, small 1106 -> 443 MB, at 1.01-1.08x the speed and with
bit-identical output. ET_MLX_EVAL_EVERY overrides; 0 restores the old path.
…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
(pytorch#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.
@msluszniak
msluszniak force-pushed the ms/mlx-bound-lazy-graph-memory branch from debdde0 to aa47f06 Compare September 18, 2026 12:09
@nil-is-all nil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Sep 18, 2026
@metascroy

Copy link
Copy Markdown
Contributor

Thanks for the PR @msluszniak!

Could we expose this as a per-handle runtime backend option named eval_threshold_bytes, rather than an env-only setting? MLX already has the LoadBackendOptionsMap / get_runtime_spec pattern for clear_cache_interval. I’d follow that pattern with kEvalThresholdBytesKey, validate the value at load time, and configure the interpreter before the init chain runs. Also document this new option in the our docs.

The default should be 0 (disabled), preserving existing behavior. Disabled should mean no meaningful overhead from this mechanism: no tensor traversal, nbytes() queries, byte accumulation, or evaluation-root collection. Please guard the accounting itself, not just the final evaluation. Existing op-internal eval calls should remain unchanged.

For nested chains, we could share size_t& pending_bytes through run_chain, exec_if, and exec_scan. Ordinary dispatch handlers wouldn’t need to change; the surrounding loop would accumulate their estimates only when the option is enabled.

Add an accumulate_only parameter to run_chain, with nested calls passing true:

run_chain(/* ... */, pending_bytes, /*accumulate_only=*/true);

After the existing SCAN/IF/dispatch block, the outer loop would look conceptually like this; helper names are illustrative:

if (eval_threshold_bytes != 0) {
  if (instr.op != OpCode::SCAN && instr.op != OpCode::IF) {
    accumulate_instruction_bytes(instr, st, pending_bytes);
  }

  if (!accumulate_only && pending_bytes >= eval_threshold_bytes) {
    evaluate_state_tensors(st);
    pending_bytes = 0;
  }
}

This avoids double-counting IF/SCAN’s child instructions at the parent level. SCAN’s final stack operation should be accounted for separately, also guarded by the option being enabled. Accumulation should be overflow-safe.

accumulate_only suppresses only threshold-triggered evaluation, not op-internal evaluations. Deferring the threshold check until exec_scan returns means its collected outputs have been stacked into state and are reachable from the evaluation roots. Evaluating inside the body could otherwise reset the shared counter while leaving earlier outputs retained only in collected unevaluated.

“Threshold” is intentional: this is best-effort evaluation scheduling, not a hard memory limit. A long SCAN or IF branch can exceed the threshold before returning, and internal evaluations can make the estimate overcount pending work. Please document those limitations.

I’d add focused tests for nested accumulation and threshold triggering, output equivalence, independent per-handle settings, invalid values, and the disabled path, specifically verifying that disabled execution never invokes the accounting helper. Representative memory and latency measurements should validate the enabled behavior.

Replaces the ET_MLX_EVAL_BUDGET_MB env var with a per-handle
eval_threshold_bytes runtime spec, following the clear_cache_interval
pattern. It is read in init() before the init chain runs, validated
there, and defaults to 0 (disabled).

Disabled now means no work at all: the traversal, the nbytes() queries,
the accumulation and the evaluation-root collection all sit behind the
threshold check, not just the evaluation itself.

Nested chains share the caller's counter through run_chain's new
pending_bytes/accumulate_only parameters, so IF branches and SCAN bodies
accumulate but do not trigger an evaluation of their own; the enclosing
chain checks once control returns to it, by which point a SCAN's
collected outputs are stacked into state and reachable from the roots.
SCAN and IF are not charged at the parent level, since their children
already were. SCAN's final stack is charged separately. Accumulation
saturates so it cannot wrap back under the threshold.

Documents that this is a threshold and not a hard limit, and the three
ways peak can exceed it.
@msluszniak

Copy link
Copy Markdown
Contributor Author

@metascroy could you look if now the implementation follows the established flow?

@msluszniak

msluszniak commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Eh, apparently I cannot fix the linter 😅, my bad, will fix this in sec.

The new test block was inserted between that comment and its et_cxx_test,
which also left cmake-format wanting to reflow the two comments into one.
Comment thread docs/source/backends/mlx/mlx-overview.md
@mergennachin

Copy link
Copy Markdown
Contributor

Please update the PR description to match the implementation: eval_threshold_bytes is now a runtime option, disabled by default. The description still refers to ET_MLX_EVAL_BUDGET_MB.

@mergennachin

Copy link
Copy Markdown
Contributor

I'll let @metascroy approve, but looks good to me

BackendOptions comes from runtime/backend/options.h; backend_options.h
only carries the key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants