Skip to content

Commit 574bfca

Browse files
[MLX] Reduce physical footprint memory in MLX (#20342)
Handlers create temp slots during node processing. This PR introduces a context manager that returns these slots to the slot pool after the handler executes so they can be reused by future handlers/instructions. Reduces phys_footprint on a 4k context export of Gemma4-31B by 10.54 GiB (13.16 GiB to 2.62 GiB). --------- Co-authored-by: uddeshsingh <uddeshsingh@gmail.com>
1 parent b4203aa commit 574bfca

5 files changed

Lines changed: 323 additions & 37 deletions

File tree

.github/workflows/mlx.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
backends/mlx/test/test_pattern_utils.py \
8080
backends/mlx/test/test_partitioner.py \
8181
backends/mlx/test/test_serialization_dedup.py \
82+
backends/mlx/test/test_slot_recycling.py \
8283
examples/models/gemma4_31b/quant/tests/test_pack_mlx.py \
8384
examples/models/gemma4_31b/tests/test_mlx_pipeline.py \
8485
-v

backends/mlx/builder/program_builder.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ def make_tmp_value_slot(self) -> Tuple[str, Slot]:
242242
"""Create a temporary value (SymInt) slot."""
243243
return self.slot_manager.make_tmp_value_slot()
244244

245+
def tmp_scope(self):
246+
"""Context manager scoping temporary slot ids for reuse.
247+
248+
See :meth:`SlotManager.tmp_scope`.
249+
"""
250+
return self.slot_manager.tmp_scope()
251+
245252
def make_or_get_constant(self, name: str, tensor: torch.Tensor) -> Slot:
246253
"""
247254
Creates an extra constant outside of the ExportedProgram state_dict.
@@ -529,7 +536,8 @@ def _process_nodes(self) -> None: # noqa C901
529536

530537
if self.node_info[n].handler is not None:
531538
handler = self.node_info[n].handler
532-
handler(self, n)
539+
with self.tmp_scope():
540+
handler(self, n)
533541
self._mark_supported(n, handler=handler)
534542
continue
535543

@@ -558,7 +566,8 @@ def _process_nodes(self) -> None: # noqa C901
558566
continue
559567

560568
try:
561-
handler(self, n)
569+
with self.tmp_scope():
570+
handler(self, n)
562571
self._mark_supported(n, handler=handler)
563572
except Exception as e:
564573
trace_str = traceback.format_exc()
@@ -688,14 +697,26 @@ def _collect_used_slots(
688697
# Inputs, outputs, mutable buffers - always include
689698
used_slots.add(s)
690699

700+
# Count distinct physical slots. Slots that share (id_space, idx) are the
701+
# same slot reused across disjoint lifetimes (delete-as-you-go reclaim /
702+
# tmp_scope) and are coalesced to a single global id below, so they must
703+
# be counted once. (For non-tensors, SymInt/SymBool share the vid pool.)
704+
#
705+
# NOTE: the key here is (is_tensor, id_space, idx), while
706+
# _create_slot_mappings keys only on (id_space, idx). The two stay
707+
# equivalent only because tids and vids are coalesced in separate passes
708+
# there (is_tensor is constant within each), so this count matches the
709+
# number of distinct global ids per space. Keep the two in sync.
691710
num_tensors: Dict[IdSpace, int] = defaultdict(int)
692711
num_values: Dict[IdSpace, int] = defaultdict(int)
693-
seen: Set[Slot] = set()
712+
seen_keys: Set[Tuple[bool, IdSpace, int]] = set()
694713
for s in used_slots:
695-
if s in seen:
714+
is_tensor = s.id_type == IdType.Tensor
715+
key = (is_tensor, s.id_space, s.idx)
716+
if key in seen_keys:
696717
continue
697-
seen.add(s)
698-
if s.id_type == IdType.Tensor:
718+
seen_keys.add(key)
719+
if is_tensor:
699720
num_tensors[s.id_space] += 1
700721
else:
701722
num_values[s.id_space] += 1
@@ -719,19 +740,28 @@ def _create_slot_mappings(
719740
IdSpace.Temp: 4,
720741
}
721742

743+
# Coalesce slots that share (id_space, idx) to a single global id. Such
744+
# slots are the same physical slot reused across disjoint lifetimes
745+
# (delete-as-you-go reclaim / tmp_scope), so they must map to the same
746+
# global Tid/Vid. Sorting by (id_space, idx) keeps per-space id ranges
747+
# contiguous, matching the counts from _collect_used_slots.
748+
def _coalesce(slots: List[Slot]) -> Dict[Slot, int]:
749+
mapping: Dict[Slot, int] = {}
750+
key_to_global: Dict[Tuple[IdSpace, int], int] = {}
751+
for s in sorted(slots, key=lambda s: (id_space_order[s.id_space], s.idx)):
752+
key = (s.id_space, s.idx)
753+
gid = key_to_global.get(key)
754+
if gid is None:
755+
gid = len(key_to_global)
756+
key_to_global[key] = gid
757+
mapping[s] = gid
758+
return mapping
759+
722760
# Create Tid mapping
723-
slot_to_tid = sorted(
724-
[s for s in used_slots if s.id_type == IdType.Tensor],
725-
key=lambda s: (id_space_order[s.id_space], s.idx),
726-
)
727-
slot_to_tid = {s: idx for idx, s in enumerate(slot_to_tid)}
761+
slot_to_tid = _coalesce([s for s in used_slots if s.id_type == IdType.Tensor])
728762

729763
# Create Vid mapping
730-
slot_to_vid = sorted(
731-
[s for s in used_slots if s.id_type != IdType.Tensor],
732-
key=lambda s: (id_space_order[s.id_space], s.idx),
733-
)
734-
slot_to_vid = {s: idx for idx, s in enumerate(slot_to_vid)}
764+
slot_to_vid = _coalesce([s for s in used_slots if s.id_type != IdType.Tensor])
735765

736766
# Remap all Tid/Vid values in instructions to use global indices
737767
if hasattr(self, "_tid_slot_map"):

backends/mlx/builder/slot_manager.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
import uuid
1010
from collections import defaultdict
11+
from contextlib import contextmanager
1112
from dataclasses import dataclass
1213
from enum import auto, Enum
13-
from typing import Dict, Optional, Tuple, Union
14+
from typing import Dict, Iterator, List, Optional, Tuple, Union
1415

1516
import torch
1617
from torch.fx.node import Node
@@ -73,10 +74,72 @@ def __init__(self):
7374
self.tid_managers: Dict[IdSpace, IdManager] = defaultdict(IdManager)
7475
self.vid_managers: Dict[IdSpace, IdManager] = defaultdict(IdManager)
7576
self.name_to_slot: Dict[str, Slot] = {}
77+
# Stack of active temp-slot scopes (see ``tmp_scope``). Temp tids/vids
78+
# allocated via make_tmp_slot()/make_tmp_value_slot() are registered on
79+
# the innermost scope and their ids returned for reuse on scope exit.
80+
self._tmp_scopes: List[List[Slot]] = []
81+
82+
@contextmanager
83+
def tmp_scope(self) -> Iterator[None]:
84+
"""Scope temporary slot allocations so their ids can be reused.
85+
86+
Temp tids/vids allocated via :meth:`make_tmp_slot` /
87+
:meth:`make_tmp_value_slot` inside this context are returned to their
88+
id pools when the context exits, so later allocations (temp or node)
89+
can reuse them. Allocating a temp slot outside any ``tmp_scope`` raises
90+
``RuntimeError``.
91+
92+
Scopes may be nested; each allocation is tied to the innermost scope.
93+
The Slot objects stay in ``name_to_slot`` (mirroring node-slot reclaim
94+
via ``return_id``) so serialization still sees every distinct slot.
95+
96+
Invariant: the slots yielded here are dead after the context exits.
97+
Never ``set_slot`` a temp slot as a node's persistent output because its id is
98+
reclaimed on scope exit and would be reused (and coalesced) by a later
99+
node while still live. Node outputs must come from ``make_or_get_slot``.
100+
"""
101+
self._tmp_scopes.append([])
102+
try:
103+
yield
104+
finally:
105+
scope = self._tmp_scopes.pop()
106+
for slot in scope:
107+
if slot.id_type == IdType.Tensor:
108+
self.tid_managers[slot.id_space].return_id(slot.idx)
109+
else:
110+
self.vid_managers[slot.id_space].return_id(slot.idx)
111+
112+
def _new_tmp_slot(self, id_type: IdType, prefix: str) -> Tuple[str, Slot]:
113+
if not self._tmp_scopes:
114+
raise RuntimeError(
115+
f"{prefix}() must be called within a SlotManager.tmp_scope() "
116+
"context so temporary ids can be reclaimed and reused."
117+
)
118+
name = f"{prefix}_{uuid.uuid4().hex}"
119+
id_space = IdSpace.Temp
120+
manager = (
121+
self.tid_managers[id_space]
122+
if id_type == IdType.Tensor
123+
else self.vid_managers[id_space]
124+
)
125+
idx = manager.get_id()
126+
slot = Slot(id_type=id_type, id_space=id_space, idx=idx)
127+
self.name_to_slot[name] = slot
128+
self._tmp_scopes[-1].append(slot)
129+
return name, slot
76130

77131
def set_slot(self, node_or_name: Union[Node, str], slot: Slot):
78132
if isinstance(node_or_name, Node):
79133
node_or_name = node_or_name.name
134+
# A slot still tracked by an active tmp_scope has its id reclaimed when the
135+
# scope exits, so it must never be bound as a node's persistent output (a
136+
# later node would read it as dead). Node outputs must come from
137+
# make_or_get_slot(). See SlotManager.tmp_scope().
138+
assert not any(slot in scope for scope in self._tmp_scopes), (
139+
f"Cannot bind temporary slot {slot} as the output of {node_or_name}; "
140+
f"its id is reclaimed on tmp_scope exit. Use make_or_get_slot() for "
141+
f"node outputs."
142+
)
80143
# Allow setting a slot to the same value (e.g., for in-place ops like SLICE_UPDATE)
81144
existing = self.name_to_slot.get(node_or_name)
82145
if existing is not None:
@@ -129,23 +192,11 @@ def make_constant_slot(self, name: str) -> Slot:
129192
return slot
130193

131194
def make_tmp_slot(self) -> Tuple[str, Slot]:
132-
name = f"tmp_{uuid.uuid4().hex}"
133-
id_space = IdSpace.Temp
134-
manager = self.tid_managers[id_space]
135-
idx = manager.get_id()
136-
slot = Slot(id_type=IdType.Tensor, id_space=id_space, idx=idx)
137-
self.name_to_slot[name] = slot
138-
return name, slot
195+
return self._new_tmp_slot(IdType.Tensor, "tmp")
139196

140197
def make_tmp_value_slot(self) -> Tuple[str, Slot]:
141198
"""Create a temporary SymInt slot and register it."""
142-
name = f"tmp_val_{uuid.uuid4().hex}"
143-
id_space = IdSpace.Temp
144-
manager = self.vid_managers[id_space]
145-
idx = manager.get_id()
146-
slot = Slot(id_type=IdType.SymInt, id_space=id_space, idx=idx)
147-
self.name_to_slot[name] = slot
148-
return name, slot
199+
return self._new_tmp_slot(IdType.SymInt, "tmp_val")
149200

150201
def make_or_get_slots(
151202
self, node: Node, id_space: IdSpace = IdSpace.Temp

backends/mlx/custom_kernel_ops/gated_delta_rule.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,11 @@ def _emit_metal_kernel(self, P: MLXProgramBuilder, n: Node) -> Slot:
305305
# otherwise create a new temp slot.
306306
out = P.make_or_get_slot(self.getitem_0)
307307

308-
# Output slot for state_out (carry)
309-
_, carry = P.make_tmp_slot()
308+
# Output slot for state_out (carry). This is node n's persistent output
309+
# (the mutated state), so it must be a node-owned slot — not a temp slot,
310+
# whose id is reclaimed on tmp_scope exit and would be read as dead by a
311+
# later node that consumes the mutated state (e.g. a second op call).
312+
carry = P.make_or_get_slot(n)
310313

311314
# Metal kernel source (non-vectorized, no mask variant from mlx-lm)
312315
source = """
@@ -428,7 +431,7 @@ def _emit_metal_kernel(self, P: MLXProgramBuilder, n: Node) -> Slot:
428431
)
429432

430433
# HEAD is getitem[1] = mutated state → bind to carry
431-
P.set_slot(n, carry)
434+
# carry already registered as n's slot via make_or_get_slot(n) above.
432435
P.set_slot(self.getitem_0, out)
433436

434437
return carry
@@ -447,8 +450,11 @@ def _emit_scan(self, P: MLXProgramBuilder, n: Node) -> Slot:
447450
]
448451
)
449452

450-
# Carry needs a writable temp slot
451-
_, carry = P.make_tmp_slot()
453+
# Carry needs a writable slot. This is node n's persistent output (the
454+
# mutated state), so it must be a node-owned slot — not a temp slot, whose
455+
# id is reclaimed on tmp_scope exit and would be read as dead by a later
456+
# node that consumes the mutated state (e.g. a second op call).
457+
carry = P.make_or_get_slot(n)
452458
P.emit(IdCopyNode(x=P.slot_to_tid(state_slot), out=P.slot_to_tid(carry)))
453459

454460
# Sliced temp slots for per-step inputs
@@ -543,7 +549,7 @@ def _emit_scan(self, P: MLXProgramBuilder, n: Node) -> Slot:
543549
)
544550

545551
# HEAD is getitem[1] = mutated state → bind to carry
546-
P.set_slot(n, carry)
552+
# carry already registered as n's slot via make_or_get_slot(n) above.
547553

548554
# Set getitem[0] slot → output tensor (for downstream computation)
549555
P.set_slot(self.getitem_0, out)

0 commit comments

Comments
 (0)