Skip to content

Commit eaa2d0b

Browse files
authored
Add weighted einsum implementation (#671)
* more precise stream type * add tests for weighted rules * add reduction rule for weighted streams and tests * add test to demo expectation * add numpyro monoid module * add quadrature * add tests * wip * refactor tests * wip * test composition of lifting and weighting * drop numpyro changes * drop unused ops * lint * make weighted a Monoid method * fix typing of jax arrays * change weighted typing to take callable * fix test * fix test * resolve type aliases before dispatching * wip * wip * remove typeof_full * wip * wip * wip * format * refactor test harness * fix behavior of delta terms * add baseline einsum * rework einsum to work on shapes instead of concrete tensors * add einsum benchmark * wip * wip * finish sum/product contraction * allow bind_dims to bind nonexistent named dimensions * wip * add custom partial eval for reductions * working benchmarks * fix infinite loop * eliminate identity indexing when possible * wip * handle getitem where dimensions are created * treat any index with bare ops and slice(None) as canonical * simplify range op and add reduction rules * wip * remove old benchmark code * another try at removing identity gathers * refactor * fix test * lint * clean up comment * fix some test failures * drop sketchy bind_dims rule * drop more type-incompatible plus rules * format * fix reduction issue * drop dimension creating behavior from bind_dims * lint * simplify comment * drop partition * fix docstring * handle negative dimension indexing * fix creation of empty tensors * fully restore previous behavior for missing named dims * reduce any arraylike or named tensor * require at least one jax array to reduce * fix typing test * drop typing test * drop einsum parser in favor of opt_einsum * more agressive factorization that hoists shared streams * reduce nesting * comment * replace with simpler push-based rule * format * drop unused disjoint set * remove unused * push multiple streams instead of one at a time * drop contraction ordering handler * fold BindDimsBindDims into default behavior * handle Sum.reduce instead of Monoid.reduce * wip * wip * hacks * extract contraction heuristic * lint * fix test * use a named dimension einsum for contractions * lint * drop custom arange op * wip * simplify by targetting delta rules * wip * fixes * fixes * lint * drop unused * pick up constants but not rest of module * lint
1 parent 8547ad6 commit eaa2d0b

10 files changed

Lines changed: 982 additions & 396 deletions

File tree

effectful/handlers/jax/_handlers.py

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import functools
2+
import itertools
23
import typing
3-
from collections.abc import Callable, Mapping, Sequence
4+
from collections.abc import Callable, Iterable, Mapping, Sequence
45
from types import EllipsisType
56
from typing import Annotated
67

8+
from opt_einsum import get_symbol
9+
from opt_einsum.parser import parse_einsum_input
10+
711
try:
812
import jax
913
import jax.numpy as jnp
1014
except ImportError:
1115
raise ImportError("JAX is required to use effectful.handlers.jax")
1216

1317
from effectful.internals.runtime import interpreter
14-
from effectful.ops.semantics import apply, evaluate, fvsof, typeof
18+
from effectful.ops.semantics import apply, evaluate, fvsof, fwd, typeof
1519
from effectful.ops.syntax import (
1620
Scoped,
1721
_CustomSingleDispatchCallable,
@@ -66,9 +70,13 @@ def update_sizes(sizes, op, size):
6670

6771
def _getitem_sizeof(x: jax.Array, key: tuple[Expr[IndexElement], ...]):
6872
if is_eager_array(x):
69-
for i, k in enumerate(key):
73+
i = 0
74+
for k in key:
7075
if isinstance(k, Term) and len(k.args) == 0 and len(k.kwargs) == 0:
7176
update_sizes(sizes, k.op, x.shape[i])
77+
if k is not None:
78+
i += 1
79+
7280
return defdata(jax_getitem, x, key)
7381

7482
def _apply(op, *args, **kwargs):
@@ -89,8 +97,9 @@ def _partial_eval(t: Expr[jax.Array]) -> Expr[jax.Array]:
8997

9098
# if any dimension is zero sized, the result is empty
9199
if any(size == 0 for size in sized_fvs.values()):
92-
key = tuple(sized_fvs.keys())
93-
shape = tuple(sized_fvs[k] for k in key)
100+
ops = tuple(sized_fvs.keys())
101+
key = tuple(k() for k in ops)
102+
shape = tuple(sized_fvs[k] for k in ops)
94103
return jax_getitem(jnp.empty(shape), key)
95104

96105
def _is_eager(t):
@@ -142,7 +151,11 @@ def _jax_op(*args, **kwargs) -> jax.Array:
142151
and not isinstance(args[0], Term)
143152
and sized_fvs
144153
and args[1]
145-
and all(isinstance(k, Term) and k.op in sized_fvs for k in args[1])
154+
and all(
155+
(isinstance(k, Term) and k.op in sized_fvs)
156+
or (isinstance(k, slice) and k == slice(None))
157+
for k in args[1]
158+
)
146159
):
147160
raise NotHandled
148161
elif sized_fvs and set(sized_fvs.keys()) == fvsof(tm) - {jax_getitem, _jax_op}:
@@ -182,6 +195,93 @@ def _jax_op(*args, **kwargs) -> jax.Array:
182195
return _jax_op
183196

184197

198+
def _named_dims(term: Expr[jax.Array]) -> tuple[Operation, ...]:
199+
if not (isinstance(term, Term) and term.op == jax_getitem):
200+
return ()
201+
index = term.args[1]
202+
assert isinstance(index, Iterable)
203+
return tuple(i.op for i in index if isinstance(i, Term) and not i.args)
204+
205+
206+
def _reduce_named(array, axis=None, **kwargs) -> jax.Array:
207+
if axis is None:
208+
return fwd()
209+
210+
named_dims = _named_dims(array)
211+
if not named_dims:
212+
return fwd()
213+
214+
bound_arr = bind_dims(array, *named_dims)
215+
216+
if isinstance(axis, int):
217+
axis = (axis,)
218+
shifted_axis = tuple(a + len(named_dims) if a >= 0 else a for a in axis)
219+
220+
reduced = fwd(bound_arr, axis=shifted_axis, **kwargs)
221+
return unbind_dims(reduced, *named_dims)
222+
223+
224+
def _einsum_named(subscripts, *operands, **kwargs) -> jax.Array:
225+
# only the string-subscripts form is handled; forward the interleaved form
226+
if not isinstance(subscripts, str):
227+
if any(isinstance(x, Term) for x in (subscripts, *operands)):
228+
raise ValueError("Interleaved einsum is not implemented with named tensors")
229+
return jax.numpy.einsum(subscripts, *operands, **kwargs)
230+
231+
# forward if any operand has a symbolic (Term) shape
232+
if any(isinstance(arr.shape, Term) for arr in operands):
233+
raise NotHandled
234+
235+
named = [_named_dims(op) for op in operands]
236+
237+
# normalize: expand ellipses and make the output explicit, using the
238+
# positional shapes (shapes=True avoids materializing the operands)
239+
shapes = [op.shape for op in operands]
240+
in_part, out_part, _ = parse_einsum_input([subscripts, *shapes], shapes=True)
241+
in_specs = in_part.split(",")
242+
assert len(in_specs) == len(operands)
243+
244+
# fresh symbols for named dims, avoiding every symbol already in use;
245+
# get_symbol gives an effectively unlimited supply (spills into unicode)
246+
used = {c for c in (in_part + out_part) if c not in ",->"}
247+
counter = itertools.count()
248+
249+
def next_symbol():
250+
while True:
251+
s = get_symbol(next(counter))
252+
if s not in used:
253+
used.add(s)
254+
return s
255+
256+
# assign a letter per unique named dim; shared names reuse the same letter
257+
# so einsum aligns them as batch dims rather than contracting
258+
letter_of, order = {}, []
259+
for dims in named:
260+
for d in dims:
261+
if d not in letter_of:
262+
letter_of[d] = next_symbol()
263+
order.append(d)
264+
265+
# bind named dims to leading positional axes and prepend their letters
266+
bound, new_in_specs = [], []
267+
for op, dims, spec in zip(operands, named, in_specs):
268+
bound.append(bind_dims(op, *dims) if dims else op)
269+
new_in_specs.append("".join(letter_of[d] for d in dims) + spec)
270+
271+
# add every named dim to the front of the output as passthrough
272+
out_prefix = "".join(letter_of[d] for d in order)
273+
new_subscripts = ",".join(new_in_specs) + "->" + out_prefix + out_part
274+
275+
result = jax.numpy.einsum(new_subscripts, *bound, **kwargs)
276+
277+
# unbind: leading axes correspond to `order`, reindex them back to named
278+
reindexed = jax_getitem(
279+
result,
280+
tuple(d() for d in order) + tuple(slice(None) for _ in range(len(out_part))),
281+
)
282+
return reindexed
283+
284+
185285
@_register_jax_op
186286
def jax_getitem(x: jax.Array, key: tuple[IndexElement, ...]) -> jax.Array:
187287
"""Operation for indexing an array. Unlike the standard __getitem__ method,
@@ -215,6 +315,8 @@ def bind_dims[T, A, B](
215315
>>> bind_dims(t, b, a).shape
216316
(3, 2)
217317
"""
318+
if isinstance(value, Term) and value.op == bind_dims:
319+
return bind_dims(value.args[0], *(names + tuple(value.args[1:])))
218320
if jax.tree_util.treedef_is_leaf(jax.tree.structure(value)):
219321
return __dispatch(typeof(value))(value, *names)
220322
return jax.tree.map(lambda v: bind_dims(v, *names), value)

effectful/handlers/jax/_terms.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -467,31 +467,66 @@ def _bind_dims_array(t: jax.Array, *args: Operation[[], jax.Array]) -> jax.Array
467467
array = t.args[0]
468468
dims = t.args[1]
469469
assert isinstance(dims, Sequence)
470+
ndim = len(array.shape)
470471

471472
# ensure that the order is a subset of the named dimensions
472473
order_set = set(args)
473474
if not order_set <= set(a.op for a in dims if isinstance(a, Term)):
474475
raise NotHandled
475476

476-
# permute the inner array so that the leading dimensions are in the order
477-
# specified and the trailing dimensions are the remaining named dimensions
478-
# (or slices)
479-
reindex_dims = [
480-
i
481-
for i, o in enumerate(dims)
482-
if not isinstance(o, Term) or o.op not in order_set
483-
]
484-
dim_ops = [a.op if isinstance(a, Term) else None for a in dims]
485-
perm = (
486-
[dim_ops.index(o) for o in args]
487-
+ reindex_dims
488-
+ list(range(len(dims), len(array.shape)))
477+
def axis_op(ax: int) -> Operation | None:
478+
"""The named op of a bare index term at axis ``ax``, else ``None``."""
479+
if ax < len(dims):
480+
d = dims[ax]
481+
if isinstance(d, Term) and not d.args and not d.kwargs:
482+
return d.op
483+
return None
484+
485+
# Assign an einsum id to every axis of ``array``. Axes that share a named op
486+
# get the *same* id — a repeated op (e.g. ``arr[i(), i()]``) ties its axes
487+
# together, which einsum reads as a diagonal. Every other axis (slices, ints,
488+
# fancy indices, compound terms, and trailing positional axes) gets a unique
489+
# id, so einsum simply carries it through to be reindexed below.
490+
op_ids: dict[Operation, int] = {}
491+
in_ids: list[int] = []
492+
next_id = 0
493+
for ax in range(ndim):
494+
op = axis_op(ax)
495+
if op is not None:
496+
if op not in op_ids:
497+
op_ids[op] = next_id
498+
next_id += 1
499+
in_ids.append(op_ids[op])
500+
else:
501+
in_ids.append(next_id)
502+
next_id += 1
503+
504+
# Output order: bound args that actually appear (in the requested order,
505+
# deduplicated by the diagonal merge), then every remaining axis in
506+
# first-appearance order. einsum does the permutation and the diagonals; with
507+
# all distinct ids retained in the output it performs no reduction.
508+
present_arg_ids = [op_ids[o] for o in args if o in op_ids]
509+
seen = set(present_arg_ids)
510+
rest_ids: list[int] = []
511+
for i in in_ids:
512+
if i not in seen:
513+
seen.add(i)
514+
rest_ids.append(i)
515+
516+
array = jnp.einsum(array, in_ids, present_arg_ids + rest_ids)
517+
518+
# Re-apply the original index for each carried axis and re-name unbound op
519+
# axes. Trailing positional axes (first appearance beyond ``dims``) are left
520+
# for jax_getitem to carry implicitly.
521+
first_pos: dict[int, int] = {}
522+
for ax, i in enumerate(in_ids):
523+
first_pos.setdefault(i, ax)
524+
525+
index_expr = (slice(None),) * len(present_arg_ids) + tuple(
526+
dims[first_pos[i]] if first_pos[i] < len(dims) else slice(None)
527+
for i in rest_ids
489528
)
490-
array = jnp.transpose(array, perm)
491-
reindexed = jax_getitem(
492-
array, (slice(None),) * len(args) + tuple(dims[i] for i in reindex_dims)
493-
)
494-
return reindexed
529+
return jax_getitem(array, index_expr)
495530

496531

497532
@unbind_dims.register(jax.Array) # type: ignore

0 commit comments

Comments
 (0)