Skip to content

Commit e4cd1a8

Browse files
committed
Handle captures in jax transforms
1 parent 0a0aca7 commit e4cd1a8

4 files changed

Lines changed: 457 additions & 16 deletions

File tree

flax/nnx/extract.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import dataclasses
1818
import functools
1919
import inspect
20+
import types
2021
import typing as tp
2122

2223
from flax import struct
@@ -1101,6 +1102,135 @@ def to_masked(tree, all_updates: list[Updates]):
11011102
is_leaf=lambda x: x is None
11021103
)
11031104

1105+
1106+
def find_captured_nodes(f) -> list[object]:
1107+
"""Find nnx Variables and graph nodes in f's closure chain.
1108+
1109+
Recursively searches through ``__closure__`` attributes at each level
1110+
and follows ``__wrapped__`` and ``functools.partial`` chains so that
1111+
Variables captured by decorators are also discovered.
1112+
"""
1113+
seen: set[int] = set()
1114+
captured_nodes: list[object] = []
1115+
1116+
def _collect_from_pytree(obj):
1117+
for _, value in graphlib.iter_graph(obj, graph=True):
1118+
if isinstance(value, variablelib.Variable):
1119+
obj_id = id(value)
1120+
if obj_id not in seen:
1121+
seen.add(obj_id)
1122+
captured_nodes.append(value)
1123+
1124+
def _search(func):
1125+
if isinstance(func, functools.partial):
1126+
_collect_from_pytree((func.args, func.keywords))
1127+
_search(func.func)
1128+
return
1129+
if hasattr(func, '__closure__') and func.__closure__ is not None:
1130+
for cell in func.__closure__:
1131+
obj = cell.cell_contents
1132+
_collect_from_pytree(obj)
1133+
if hasattr(func, '__wrapped__'):
1134+
_search(func.__wrapped__)
1135+
1136+
_search(f)
1137+
return captured_nodes
1138+
1139+
1140+
def _make_cell(val):
1141+
"""Create a closure cell containing val."""
1142+
x = val
1143+
return (lambda: x).__closure__[0] # type: ignore
1144+
1145+
1146+
def replace_closure_cells(
1147+
f: tp.Callable,
1148+
captured_info: list[object],
1149+
replacements: tuple,
1150+
) -> tp.Callable:
1151+
"""Return a copy of f with captured closure cells replaced by replacements.
1152+
1153+
Uses identity matching to replace Variables at every level of the
1154+
``__wrapped__`` / ``functools.partial`` chain, so that Variables captured
1155+
by intermediate decorators are also replaced.
1156+
"""
1157+
if not captured_info:
1158+
return f
1159+
id_to_replacement = {
1160+
id(node): new_val
1161+
for node, new_val in zip(captured_info, replacements)
1162+
}
1163+
return _replace_closure_cells(f, id_to_replacement)
1164+
1165+
1166+
def _replace_variables_in_pytree(
1167+
obj: tp.Any,
1168+
id_to_replacement: dict[int, tp.Any],
1169+
) -> tp.Any:
1170+
"""Replace Variables nested inside a pytree, returning the original if unchanged."""
1171+
is_leaf = lambda x: isinstance(x, variablelib.Variable)
1172+
def _replace(x):
1173+
if isinstance(x, variablelib.Variable) and id(x) in id_to_replacement:
1174+
return id_to_replacement[id(x)]
1175+
return x
1176+
return jax.tree.map(_replace, obj, is_leaf=is_leaf)
1177+
1178+
1179+
def _replace_closure_cells(
1180+
f: tp.Callable,
1181+
id_to_replacement: dict[int, tp.Any],
1182+
) -> tp.Callable:
1183+
if isinstance(f, functools.partial):
1184+
new_func = _replace_closure_cells(f.func, id_to_replacement)
1185+
new_args = _replace_variables_in_pytree(f.args, id_to_replacement)
1186+
new_keywords = _replace_variables_in_pytree(f.keywords, id_to_replacement)
1187+
if new_func is f.func and new_args is f.args and new_keywords is f.keywords:
1188+
return f
1189+
return functools.partial(new_func, *new_args, **new_keywords)
1190+
1191+
cells: list | None = None
1192+
1193+
# Replace captured Variables in this function's closure.
1194+
if hasattr(f, '__closure__') and f.__closure__ is not None:
1195+
cells = list(f.__closure__)
1196+
for i, cell in enumerate(cells):
1197+
try:
1198+
obj = cell.cell_contents
1199+
except ValueError:
1200+
continue
1201+
if id(obj) in id_to_replacement:
1202+
cells[i] = _make_cell(id_to_replacement[id(obj)])
1203+
else:
1204+
# Search inside pytree cell contents for nested Variables.
1205+
new_obj = _replace_variables_in_pytree(obj, id_to_replacement)
1206+
if new_obj is not obj:
1207+
cells[i] = _make_cell(new_obj)
1208+
1209+
# Recurse into __wrapped__ and update the cell referencing the old inner.
1210+
if hasattr(f, '__wrapped__'):
1211+
old_inner = f.__wrapped__
1212+
new_inner = _replace_closure_cells(old_inner, id_to_replacement)
1213+
if new_inner is not old_inner:
1214+
if cells is None and hasattr(f, '__closure__') and f.__closure__ is not None:
1215+
cells = list(f.__closure__)
1216+
if cells is not None:
1217+
for i, cell in enumerate(cells):
1218+
try:
1219+
if cell.cell_contents is old_inner:
1220+
cells[i] = _make_cell(new_inner)
1221+
break
1222+
except ValueError:
1223+
continue
1224+
1225+
new_f = types.FunctionType(
1226+
f.__code__, f.__globals__, f.__name__,
1227+
f.__defaults__, tuple(cells) if cells else f.__closure__,
1228+
)
1229+
if hasattr(f, '__wrapped__'):
1230+
new_f.__wrapped__ = new_inner
1231+
return new_f
1232+
1233+
11041234
def filter_kwargs(f, **kwargs):
11051235
sig = inspect.signature(f)
11061236
has_var_keyword = any(

flax/nnx/transforms/compilation.py

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -426,31 +426,62 @@ class SimpleJitFn:
426426
donate_argnames: frozenset[str]
427427
graph: bool
428428
update_shardings: tuple[tp.Any, ...]
429+
captured_info: tp.Optional[list[tp.Any]]
429430

430431
def __post_init__(self):
431432
functools.update_wrapper(self, self.f, updated=())
433+
# When captures are prepended as an extra argument, the original
434+
# function's signature no longer matches the physical call signature.
435+
# Remove __wrapped__ so inspect.signature (used by JAX for validation
436+
# of static_argnums / donate_argnums) sees *args instead.
437+
if self.captured_info:
438+
delattr(self, '__wrapped__')
432439

433440
@extract.treemap_copy_args
434441
def __call__(self, *args, **kwargs):
435-
current, snapshot = extract.snapshot(
436-
labeled(args=args, kwargs=kwargs)
437-
)
442+
if self.captured_info:
443+
captured_args = args[0]
444+
user_args = args[1:]
445+
captured_shardings = self.in_shardings[0] if isinstance(self.in_shardings, tuple) else None
446+
user_shardings = self.in_shardings[1:] if isinstance(self.in_shardings, tuple) else self.in_shardings
447+
current, snapshot = extract.snapshot(
448+
labeled(captured_args=captured_args, args=user_args, kwargs=kwargs)
449+
)
450+
else:
451+
user_args = args
452+
current, snapshot = extract.snapshot(
453+
labeled(args=user_args, kwargs=kwargs)
454+
)
438455
if self.graph:
439456
args, kwargs = extract.from_tree2((args, kwargs))
440-
out = self.f(*args, **kwargs)
457+
if self.captured_info:
458+
captured_args = args[0]
459+
user_args = args[1:]
460+
f = extract.replace_closure_cells(
461+
self.f, self.captured_info, captured_args)
462+
else:
463+
user_args = args
464+
f = self.f
465+
out = f(*user_args, **kwargs)
441466
if self.graph:
442467
out = extract.to_tree2(out, prefix=self.out_shardings)
443468
extract.check_no_aliases('jit', **current, out=out, check=['out'])
444469
def keep_fn(jax_path, prefix, c, s):
445470
if extract.variable_changed(c, s):
446471
return True
447472
arg_type, arg_key, *_ = graphlib.jax_to_nnx_path(jax_path)
473+
if arg_type == 'captured_args':
474+
return True
448475
if arg_type == 'args':
449476
return arg_key in self.donate_argnums
450477
else: # arg_type == 'kwargs':
451478
return arg_key in self.donate_argnames
479+
if self.captured_info:
480+
prefix = labeled(captured_args=captured_shardings, args=user_shardings, kwargs=None)
481+
else:
482+
prefix = labeled(args=self.in_shardings, kwargs=None)
452483
updates = extract.get_updates(
453-
current, snapshot, prefix=labeled(args=self.in_shardings, kwargs=None),
484+
current, snapshot, prefix=prefix,
454485
known_prefixes=self.update_shardings, keep_fn=keep_fn
455486
)
456487
return out, updates
@@ -533,6 +564,9 @@ def __init__(
533564
self.partial_args = partial_args
534565
self.graph = graph
535566

567+
# Capture closure nodes once at construction time
568+
self._captured_info = extract.find_captured_nodes(fun)
569+
536570
resolved = _resolve_argnums(fun, static_argnums, static_argnames)
537571
if isinstance(in_shardings, (tuple, list)) and resolved:
538572
expanded = list(in_shardings)
@@ -542,6 +576,25 @@ def __init__(
542576
else:
543577
self.in_shardings = in_shardings
544578

579+
# Prepend None shardings for captured nodes (passed as a list)
580+
n_captured = len(self._captured_info)
581+
jit_in_shardings: tp.Any
582+
if n_captured > 0:
583+
if isinstance(in_shardings, (tuple, list)):
584+
jit_in_shardings = ([None] * n_captured,) + tuple(in_shardings)
585+
elif in_shardings is not None:
586+
# Expand scalar in_shardings to match the number of user parameters
587+
# so it doesn't broadcast over the captures.
588+
sig = _fun_signature(fun)
589+
n_params = len(sig.parameters) if sig is not None else 1
590+
jit_in_shardings = ([None] * n_captured,) + tuple(
591+
in_shardings for _ in range(n_params)
592+
)
593+
else:
594+
jit_in_shardings = in_shardings
595+
else:
596+
jit_in_shardings = in_shardings
597+
545598
donate_argnums_set = frozenset(
546599
(donate_argnums,) if isinstance(donate_argnums, int)
547600
else donate_argnums or ()
@@ -550,21 +603,33 @@ def __init__(
550603
(donate_argnames,) if isinstance(donate_argnames, str)
551604
else donate_argnames or ()
552605
)
606+
607+
# When captures are prepended as an extra first argument, offset
608+
# index-based jax.jit parameters so they still refer to the correct
609+
# physical arguments.
610+
if n_captured > 0:
611+
jit_static_argnums: int | tp.Sequence[int] | None = _offset_argnums(static_argnums, 1)
612+
jit_donate_argnums: int | tp.Sequence[int] | None = _offset_argnums(donate_argnums, 1)
613+
else:
614+
jit_static_argnums = static_argnums
615+
jit_donate_argnums = donate_argnums
616+
553617
self.jitted_fn = jax.jit(
554618
SimpleJitFn(
555619
fun,
556-
self.in_shardings,
620+
jit_in_shardings if n_captured > 0 else self.in_shardings,
557621
out_shardings,
558622
donate_argnums_set,
559623
donate_argnames_set,
560624
graph,
561625
tuple(update_shardings),
626+
self._captured_info,
562627
),
563-
in_shardings=in_shardings,
628+
in_shardings=jit_in_shardings,
564629
out_shardings=(out_shardings, update_shardings),
565-
static_argnums=static_argnums,
630+
static_argnums=jit_static_argnums,
566631
static_argnames=static_argnames,
567-
donate_argnums=donate_argnums,
632+
donate_argnums=jit_donate_argnums,
568633
donate_argnames=donate_argnames,
569634
keep_unused=keep_unused,
570635
device=device,
@@ -594,10 +659,16 @@ def _maybe_from_tree(self, out):
594659
return out
595660

596661
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
597-
args = (*self.partial_args, *args) # type: ignore[assignment]
598-
args, kwargs = self._maybe_to_tree(args, kwargs)
599-
variables = extract.check_no_aliases('jit', args=args, kwargs=kwargs)
600-
out, updates = self.jitted_fn(*args, **kwargs)
662+
all_args: tuple[tp.Any, ...] = (*self.partial_args, *args)
663+
if self._captured_info:
664+
all_args = (self._captured_info, *all_args)
665+
all_args, kwargs = self._maybe_to_tree(all_args, kwargs)
666+
if self._captured_info:
667+
variables = extract.check_no_aliases(
668+
'jit', captured_args=all_args[0], args=all_args[1:], kwargs=kwargs)
669+
else:
670+
variables = extract.check_no_aliases('jit', args=all_args, kwargs=kwargs)
671+
out, updates = self.jitted_fn(*all_args, **kwargs)
601672
extract.apply_updates(variables, updates)
602673
return self._maybe_from_tree(out)
603674

@@ -608,22 +679,33 @@ def __get__(self, obj, objtype=None):
608679

609680
def eval_shape(self, *args, **kwargs):
610681
args = (*self.partial_args, *args)
682+
args = (self._captured_info, *args) if self._captured_info else args
611683
args, kwargs = self._maybe_to_tree(args, kwargs)
612684
if not self.graph:
613-
extract.check_no_aliases('jit', args=args, kwargs=kwargs)
685+
if self._captured_info:
686+
extract.check_no_aliases(
687+
'jit', captured_args=args[0], args=args[1:], kwargs=kwargs)
688+
else:
689+
extract.check_no_aliases('jit', args=args, kwargs=kwargs)
614690
out, _ = self.jitted_fn.eval_shape(*args, **kwargs)
615691
return self._maybe_from_tree(out)
616692

617693
def trace(self, *args, **kwargs):
618694
args = (*self.partial_args, *args)
695+
args = (self._captured_info, *args) if self._captured_info else args
619696
args, kwargs = self._maybe_to_tree(args, kwargs)
620697
if not self.graph:
621-
extract.check_no_aliases('jit', args=args, kwargs=kwargs)
698+
if self._captured_info:
699+
extract.check_no_aliases(
700+
'jit', captured_args=args[0], args=args[1:], kwargs=kwargs)
701+
else:
702+
extract.check_no_aliases('jit', args=args, kwargs=kwargs)
622703
traced = self.jitted_fn.trace(*args, **kwargs)
623704
return SimpleTraced(traced, self)
624705

625706
def lower(self, *args, **kwargs):
626707
args = (*self.partial_args, *args)
708+
args = (self._captured_info, *args) if self._captured_info else args
627709
args, kwargs = self._maybe_to_tree(args, kwargs)
628710
if not self.graph:
629711
extract.check_no_aliases('jit', args=args, kwargs=kwargs)
@@ -1916,6 +1998,18 @@ def shard_map_wrapper(*args, **kwargs):
19161998
return shard_map_wrapper # type: ignore
19171999

19182000

2001+
def _offset_argnums(
2002+
argnums: int | tp.Sequence[int] | None,
2003+
offset: int,
2004+
) -> int | tuple[int, ...] | None:
2005+
"""Shift argnum indices by ``offset`` (e.g. when prepending captures)."""
2006+
if argnums is None:
2007+
return None
2008+
if isinstance(argnums, int):
2009+
return argnums + offset
2010+
return tuple(i + offset for i in argnums)
2011+
2012+
19192013
# We can't use private methods from jax._src.api_util
19202014
# We copy the function: api_util.fun_signature
19212015
def _fun_signature(fun: tp.Callable) -> inspect.Signature | None:

0 commit comments

Comments
 (0)