Skip to content

Commit 4753d75

Browse files
authored
[ExecuTorch][MLX] lowering memory/export improvements (pytorch#21795)
Muse Glimmer dflash + image (16 GB Q4_K target + 1.5 GB draft + 1.3 GB mmproj): | | before | after | |---|---|---| | export time | 4.2 min | **2.8 min** (−32%) | | peak memory | 66.6 GB | **48.3 GB** (−27%) | ## What changed 1. **Support checks no longer repack weights.** The partitioner decided "can MLX lower this?" by *running* each handler, which for quantized ops means fully dequantizing and repacking the weight. exir asks several times per export, so every weight was repacked 4x. Handlers can now answer from node metadata via an optional `supported()` / `register_support_check`; anything without one keeps the old run-and-catch, so nothing regresses. 2. **Raw blobs are freed as they're repacked.** `repack_mlx` left the original GGUF bytes in the ExportedProgram until a sweep that only ran after the whole graph was built, so raw and repacked weights were live simultaneously (+16 GB). It now releases each blob on the spot, memoized per weight so tied embedding/lm_head repacks once. 3. **Two guards**, since a wrongly-released weight used to fail silently: serialization now raises naming the constant instead of emitting a `.pte` with a missing weight, and release is a no-op during support checks, where it would corrupt the caller's own program. 4. **`ET_MLX_MEM_PROFILE=1`** adds phase-attributed peak-footprint logging (off by default); it's what located the regression above.
1 parent 029c1ff commit 4753d75

23 files changed

Lines changed: 783 additions & 281 deletions

‎.github/workflows/mlx.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,52 @@ jobs:
194194
echo "Success: built $RUNNER"
195195
echo "::endgroup::"
196196
197+
# Build-only: the Muse Glimmer GGUF checkpoints are far too large to export in
198+
# CI, so this covers the compile / link / metallib wiring of the runners --
199+
# which is what MLX backend changes are most likely to break.
200+
test-mlx-muse-glimmer-build:
201+
needs: run-decision
202+
if: |
203+
github.event_name == 'pull_request' ||
204+
needs.run-decision.outputs.is-full-run == 'true'
205+
uses: pytorch/test-infra/.github/workflows/macos_job.yml@main
206+
with:
207+
default-packages: ""
208+
job-name: test-mlx-muse-glimmer-build
209+
runner: macos-14-xlarge
210+
python-version: "3.12"
211+
submodules: recursive
212+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
213+
timeout: 90
214+
script: |
215+
set -eux
216+
217+
echo "::group::Install ExecuTorch"
218+
${CONDA_RUN} python install_executorch.py > /dev/null
219+
echo "::endgroup::"
220+
221+
${CONDA_RUN} pip list
222+
223+
echo "::group::Build Muse Glimmer runners and worker with MLX"
224+
${CONDA_RUN} make muse-glimmer-mlx
225+
BIN_DIR=cmake-out/examples/models/muse-glimmer
226+
for bin in solo_runner dflash_runner muse_glimmer_worker; do
227+
if [ ! -x "${BIN_DIR}/${bin}" ]; then
228+
echo "Failed: ${bin} not found at ${BIN_DIR}/${bin}"
229+
exit 1
230+
fi
231+
done
232+
# Runtime kernel load falls back to an absolute build-tree path, so a
233+
# missing colocated metallib only breaks a relocated binary. Assert the
234+
# copy so the runners stay self-contained (needs EXECUTORCH_BUILD_MLX=ON
235+
# in the preset, which drives executorch_target_copy_mlx_metallib).
236+
if [ ! -f "${BIN_DIR}/mlx.metallib" ]; then
237+
echo "Failed: mlx.metallib not copied next to the runners"
238+
exit 1
239+
fi
240+
echo "Success: built solo_runner, dflash_runner and muse_glimmer_worker"
241+
echo "::endgroup::"
242+
197243
backend-tester:
198244
needs: run-decision
199245
if: |

‎Makefile‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
#
9292
# ==============================================================================
9393

94-
.PHONY: voxtral-cuda voxtral-cpu voxtral-metal voxtral-mlx voxtral_realtime-cuda voxtral_realtime-cpu voxtral_realtime-metal voxtral_realtime-mlx voxtral_tts-cpu voxtral_tts-cuda whisper-cuda whisper-cuda-debug whisper-cpu whisper-metal parakeet-cuda parakeet-cuda-debug parakeet-cpu parakeet-metal parakeet-mlx parakeet-vulkan dinov2-cuda dinov2-cuda-debug sortformer-cuda sortformer-cpu silero-vad-cpu llama-cuda llama-cuda-debug llama-cpu lfm_2_5-mlx llava-cpu gemma3-cuda gemma3-cpu gemma4_31b-cuda gemma4_31b-mlx muse-glimmer-cuda qwen3_5_moe-cuda qwen3_5_moe-metal qwen3_5_moe-mlx clean help
94+
.PHONY: voxtral-cuda voxtral-cpu voxtral-metal voxtral-mlx voxtral_realtime-cuda voxtral_realtime-cpu voxtral_realtime-metal voxtral_realtime-mlx voxtral_tts-cpu voxtral_tts-cuda whisper-cuda whisper-cuda-debug whisper-cpu whisper-metal parakeet-cuda parakeet-cuda-debug parakeet-cpu parakeet-metal parakeet-mlx parakeet-vulkan dinov2-cuda dinov2-cuda-debug sortformer-cuda sortformer-cpu silero-vad-cpu llama-cuda llama-cuda-debug llama-cpu lfm_2_5-mlx llava-cpu gemma3-cuda gemma3-cpu gemma4_31b-cuda gemma4_31b-mlx muse-glimmer-cuda muse-glimmer-mlx qwen3_5_moe-cuda qwen3_5_moe-metal qwen3_5_moe-mlx clean help
9595

9696
help:
9797
@echo "This Makefile adds targets to build runners for various models on various backends. Run using \`make <target>\`. Available targets:"
@@ -130,6 +130,7 @@ help:
130130
@echo " gemma4_31b-cuda - Build Gemma 4 31B runner and worker with CUDA backend"
131131
@echo " gemma4_31b-mlx - Build Gemma 4 31B runner and worker with MLX backend"
132132
@echo " muse-glimmer-cuda - Build Muse Glimmer runners and worker with CUDA backend"
133+
@echo " muse-glimmer-mlx - Build Muse Glimmer runners and worker with MLX backend"
133134
@echo " qwen3_5_moe-cuda - Build Qwen3.5 MoE runner with CUDA backend"
134135
@echo " qwen3_5_moe-metal - Build Qwen3.5 MoE runner with Metal backend"
135136
@echo " qwen3_5_moe-mlx - Build Qwen3.5 MoE runner with MLX backend"
@@ -453,6 +454,17 @@ muse-glimmer-cuda:
453454
@echo " DFlash runner: cmake-out/examples/models/muse-glimmer/dflash_runner"
454455
@echo " Worker: cmake-out/examples/models/muse-glimmer/muse_glimmer_worker"
455456

457+
muse-glimmer-mlx:
458+
@echo "==> Building and installing ExecuTorch with MLX..."
459+
cmake --workflow --preset mlx-release
460+
@echo "==> Building Muse Glimmer runners and worker with MLX..."
461+
cd examples/models/muse-glimmer && cmake --workflow --preset muse-glimmer-mlx
462+
@echo ""
463+
@echo "✓ Build complete!"
464+
@echo " Solo runner: cmake-out/examples/models/muse-glimmer/solo_runner"
465+
@echo " DFlash runner: cmake-out/examples/models/muse-glimmer/dflash_runner"
466+
@echo " Worker: cmake-out/examples/models/muse-glimmer/muse_glimmer_worker"
467+
456468
gemma4_31b-cuda:
457469
@echo "==> Building and installing ExecuTorch with CUDA..."
458470
cmake --workflow --preset llm-release-cuda

‎backends/mlx/_memprofile.py‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
"""Phase-attributed peak-memory profiling for MLX export.
10+
11+
Enabled with ``ET_MLX_MEM_PROFILE=1``; inert otherwise::
12+
13+
ET_MLX_MEM_PROFILE=1 python -m ...export_dflash --target-gguf ...
14+
15+
[mem] build: 42.84 GB -> 41.30 GB, -1.54 GB net, high-water 48.26 GB, \
16+
RAISED PEAK by 5.42 GB, 24.4s
17+
18+
Reports macOS *physical footprint* -- what ``/usr/bin/time -l`` calls "peak
19+
memory footprint" and Activity Monitor shows as "Memory". RSS is not a usable
20+
proxy: a 16 GB model export peaked at 65 GB footprint versus 37 GB RSS.
21+
22+
Only phases tagged ``RAISED PEAK`` can lower the number ``/usr/bin/time -l``
23+
reports; everything else is churn under the existing high-water mark.
24+
25+
The watermark comes from the kernel rather than a sampling thread, which would
26+
miss short-lived spikes -- a 2 GB allocation freed within 100 ms went unseen at
27+
a 50 ms sampling interval.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import ctypes
33+
import ctypes.util
34+
import os
35+
import time
36+
from contextlib import contextmanager
37+
from typing import Optional
38+
39+
from executorch.backends.mlx._logging import logger
40+
41+
_ENV_VAR = "ET_MLX_MEM_PROFILE"
42+
43+
# proc_pid_rusage(pid, flavor, rusage_info_t *buffer), from <libproc.h>.
44+
# Offsets into struct rusage_info_v4 (<sys/resource.h>): a 16-byte ri_uuid
45+
# followed by uint64 counters. Sanity-checked at runtime by _self_check().
46+
_RUSAGE_INFO_V4 = 4
47+
_OFF_PHYS_FOOTPRINT = 72
48+
_OFF_PROC_START_ABSTIME = 80
49+
_OFF_PROC_EXIT_ABSTIME = 88
50+
_OFF_LIFETIME_MAX_PHYS_FOOTPRINT = 240
51+
_RUSAGE_BUF_BYTES = 512 # generous; larger than any rusage_info_v4
52+
53+
# Bounds for the self-check: any live process is above the floor, and no real
54+
# footprint approaches the ceiling.
55+
_MIN_PLAUSIBLE = 1 << 20 # 1 MiB
56+
_MAX_PLAUSIBLE = 1 << 50 # 1 PiB
57+
58+
_libc = None
59+
_usable: Optional[bool] = None
60+
_depth = 0
61+
62+
63+
def _read_rusage() -> Optional[bytes]:
64+
global _libc
65+
try:
66+
if _libc is None:
67+
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
68+
buf = (ctypes.c_uint8 * _RUSAGE_BUF_BYTES)()
69+
if _libc.proc_pid_rusage(os.getpid(), _RUSAGE_INFO_V4, ctypes.byref(buf)) != 0:
70+
return None
71+
return bytes(buf)
72+
except (AttributeError, OSError, ValueError):
73+
return None
74+
75+
76+
def _field(raw: Optional[bytes], offset: int) -> Optional[int]:
77+
if raw is None:
78+
return None
79+
return int.from_bytes(raw[offset : offset + 8], "little")
80+
81+
82+
def _self_check() -> bool:
83+
"""Smoke-test the hardcoded rusage offsets before trusting what they return.
84+
85+
The struct layout is pinned by the flavor argument (Apple adds rusage_info_v5
86+
rather than reordering v4), so this is a guard against being wrong about the
87+
layout, not against it changing underneath us.
88+
89+
ri_proc_exit_abstime is the anchor: it is necessarily zero for the process
90+
asking, so a non-zero read means the offsets are not landing where we think.
91+
The bounds checks on the two footprint fields are weaker -- a misread can
92+
still satisfy them by coincidence, since neighbouring counters hold numbers
93+
of a similar magnitude -- so they catch gross errors only.
94+
"""
95+
raw = _read_rusage()
96+
current = _field(raw, _OFF_PHYS_FOOTPRINT)
97+
peak = _field(raw, _OFF_LIFETIME_MAX_PHYS_FOOTPRINT)
98+
started = _field(raw, _OFF_PROC_START_ABSTIME)
99+
exited = _field(raw, _OFF_PROC_EXIT_ABSTIME)
100+
101+
if current is None:
102+
reason = "proc_pid_rusage unavailable (non-macOS?)"
103+
elif exited != 0 or not started:
104+
reason = (
105+
f"struct layout unrecognized (proc_start={started}, proc_exit={exited}; "
106+
"expected a non-zero start and a zero exit for a live process)"
107+
)
108+
elif not _MIN_PLAUSIBLE <= current <= peak <= _MAX_PLAUSIBLE:
109+
reason = f"implausible readings (current={current}, lifetime max={peak})"
110+
else:
111+
return True
112+
113+
logger.warning(f"[mem] memory profiling disabled: {reason}")
114+
return False
115+
116+
117+
def enabled() -> bool:
118+
"""Whether profiling is switched on and the platform counters are trustworthy."""
119+
global _usable
120+
if os.environ.get(_ENV_VAR, "0") == "0":
121+
return False
122+
if _usable is None:
123+
_usable = _self_check()
124+
return _usable
125+
126+
127+
def phys_footprint() -> Optional[int]:
128+
"""Current physical footprint in bytes, or None if unavailable."""
129+
return _field(_read_rusage(), _OFF_PHYS_FOOTPRINT)
130+
131+
132+
def peak_footprint() -> Optional[int]:
133+
"""Process lifetime maximum physical footprint in bytes.
134+
135+
Matches the "peak memory footprint" line from ``/usr/bin/time -l``.
136+
"""
137+
return _field(_read_rusage(), _OFF_LIFETIME_MAX_PHYS_FOOTPRINT)
138+
139+
140+
def _gb(n: Optional[int]) -> str:
141+
return "?" if n is None else f"{n / (1 << 30):.2f} GB"
142+
143+
144+
def _delta(before: Optional[int], after: Optional[int]) -> Optional[int]:
145+
return None if (before is None or after is None) else after - before
146+
147+
148+
@contextmanager
149+
def mem_phase(name: str):
150+
"""Log footprint across `name`, attributing any new high-water mark to it.
151+
152+
Nested phases are indented. Does nothing unless ET_MLX_MEM_PROFILE is set.
153+
"""
154+
global _depth
155+
156+
if not enabled():
157+
yield
158+
return
159+
160+
indent = " " * _depth
161+
start, start_peak = phys_footprint(), peak_footprint()
162+
started_at = time.perf_counter()
163+
_depth += 1
164+
try:
165+
yield
166+
finally:
167+
_depth -= 1
168+
end, end_peak = phys_footprint(), peak_footprint()
169+
net = _delta(start, end)
170+
raised = _delta(start_peak, end_peak)
171+
172+
sign = "+" if net is not None and net >= 0 else ""
173+
message = (
174+
f"[mem]{indent} {name}: {_gb(start)} -> {_gb(end)}, "
175+
f"{sign}{_gb(net)} net, high-water {_gb(end_peak)}"
176+
)
177+
if raised:
178+
message += f", RAISED PEAK by {_gb(raised)}"
179+
logger.info(f"{message}, {time.perf_counter() - started_at:.1f}s")
180+
181+
182+
def log_footprint(label: str) -> None:
183+
"""Log a one-off footprint reading."""
184+
if enabled():
185+
logger.info(
186+
f"[mem] {label}: {_gb(phys_footprint())} "
187+
f"(high-water {_gb(peak_footprint())})"
188+
)

‎backends/mlx/builder/op_helpers.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,28 @@ def emit_quantized_gather(
534534
)
535535

536536

537+
def mlx_qparams_supported(
538+
in_features: int, num_groups: int, group_size: int, bits: int
539+
) -> bool:
540+
"""Whether to_mlx_qparams + regroup_affine_scales can consume this layout.
541+
542+
Mirrors their asserts using shape metadata only, so a handler can answer
543+
"would I lower this?" without reading (and repacking) the weight:
544+
545+
* to_mlx_qparams packs a row into whole uint32 words, so
546+
``in_features * bits`` must be a multiple of 32.
547+
* regroup_affine_scales repeat-interleaves, which only ever splits a group
548+
finer, so the weight's own group must be a whole multiple of the
549+
MLX-legal ``group_size``.
550+
"""
551+
if in_features <= 0 or num_groups <= 0 or in_features % num_groups != 0:
552+
return False
553+
if (in_features * bits) % 32 != 0:
554+
return False
555+
weight_group_size = in_features // num_groups
556+
return weight_group_size >= group_size and weight_group_size % group_size == 0
557+
558+
537559
def to_mlx_qparams(
538560
qdata: torch.Tensor,
539561
scale: torch.Tensor,

0 commit comments

Comments
 (0)