folx 0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
folx/hessian.py ADDED
@@ -0,0 +1,346 @@
1
+ import functools
2
+ import logging
3
+ from typing import Callable, Sequence
4
+
5
+ import jax
6
+ import jax.flatten_util as jfu
7
+ import jax.numpy as jnp
8
+ import jax.tree_util as jtu
9
+ import jaxlib.xla_extension
10
+ import numpy as np
11
+ from jax import core
12
+
13
+ from .api import (
14
+ JAC_DIM,
15
+ Array,
16
+ Axes,
17
+ CustomTraceJacHessianJac,
18
+ ExtraArgs,
19
+ ForwardFn,
20
+ FunctionFlags,
21
+ FwdJacobian,
22
+ FwdLaplArgs,
23
+ FwdLaplArray,
24
+ MergeFn,
25
+ PyTree,
26
+ )
27
+ from .utils import (
28
+ add_vmap_jacobian_dim,
29
+ array_wise_flat_wrap,
30
+ flat_wrap,
31
+ get_reduced_jacobians,
32
+ jac_jacT,
33
+ trace_jac_jacT,
34
+ trace_of_product,
35
+ vmap_sequences_and_squeeze,
36
+ )
37
+
38
+
39
+ def general_jac_hessian_jac(fn: ForwardFn, args: FwdLaplArgs, materialize_idx: Array | None):
40
+ # It's conceptually easier to work with the flattened version of the
41
+ # Hessian, since we can then use einsum to compute the trace.
42
+ flat_fn = flat_wrap(fn, *args.x)
43
+ flat_x = jfu.ravel_pytree(args.x)[0]
44
+ # We have to decide on an order in which we execute tr(HJJ^T).
45
+ # H will be of shape NxDxD, J is DxK where N could potentially be D.
46
+ # We will do the following:
47
+ # if K >= D, we compute
48
+ # JJ^T first and then the trace.
49
+ # if D < K, we compute HJ first and then the trace.
50
+ # We should also flatten our gradient tensor to a 2D matrix where the first dimension
51
+ # is the x0 dim and the second dim is the input dim.
52
+ grads_2d = get_reduced_jacobians(*args.jacobian, idx=materialize_idx)
53
+ grad_2d = jnp.concatenate([x.T for x in grads_2d], axis=0)
54
+ D, K = grad_2d.shape
55
+ if K > D:
56
+ # jax.hessian uses Fwd on Reverse AD
57
+ flat_hessian = jax.hessian(flat_fn)(flat_x)
58
+ flat_out = trace_of_product(flat_hessian, grad_2d @ grad_2d.T)
59
+ elif D > K:
60
+ # Directly copmute the trace of tr(HJJ^T)=tr(J^THJ)
61
+ @functools.partial(jax.vmap, in_axes=-1, out_axes=-1)
62
+ def vhvp(tangent):
63
+ def vjp(x):
64
+ @functools.partial(jax.vmap, in_axes=(None, -1), out_axes=-1)
65
+ def jvp(x, tangent):
66
+ return jax.jvp(flat_fn, (x,), (tangent,))[1]
67
+
68
+ return jvp(x, grad_2d)
69
+
70
+ return jax.jvp(vjp, (flat_x,), (tangent,))[1]
71
+
72
+ flat_out = jnp.trace(vhvp(grad_2d), axis1=-2, axis2=-1)
73
+ else:
74
+ # Implementation where we compute HJ and then the trace via
75
+ # the sum of hadamard product
76
+ @functools.partial(jax.vmap, in_axes=-1, out_axes=-1)
77
+ def hvp(tangent):
78
+ def jacobian(x):
79
+ return jax.jacrev(flat_fn)(x)
80
+
81
+ return jax.jvp(jacobian, (flat_x,), (tangent,))[1]
82
+
83
+ HJ = hvp(grad_2d) # N x D x K
84
+ flat_out = trace_of_product(HJ, grad_2d) # N x D x K and D x K
85
+
86
+ # since f(x) and nabla f(x) should have the same structure, we can use the
87
+ # structure of f(x) to unravel the flat_out
88
+ unravel = jfu.ravel_pytree(fn(*args.x))[1]
89
+ return unravel(flat_out)
90
+
91
+
92
+ def off_diag_jac_hessian_jac(fn: ForwardFn, args: FwdLaplArgs, materialize_idx: Array | None):
93
+ # if we know that a function is linear in one arguments, it's hessian must be off diagonal
94
+ # thus we can safe some computation by only computing the off diagonal part of the hessian.
95
+ assert len(args) == 2, "Off diag hessian only supports 2 args at the moment."
96
+
97
+ def flat_arr(x: FwdLaplArray) -> Array:
98
+ return jfu.ravel_pytree(x.x)[0]
99
+
100
+ flat_fn = array_wise_flat_wrap(fn, *args.x)
101
+
102
+ def jac_lhs(lhs, rhs):
103
+ return jax.jacobian(flat_fn, argnums=0)(lhs, rhs)
104
+
105
+ hessian = jax.jacobian(jac_lhs, argnums=1)(flat_arr(args.arrays[0]), flat_arr(args.arrays[1]))
106
+
107
+ flat_out = 2 * trace_of_product(
108
+ hessian, jac_jacT(args.arrays[0].jacobian, args.arrays[1].jacobian, materialize_idx)
109
+ )
110
+ unravel = jfu.ravel_pytree(fn(*args.x))[1]
111
+ return unravel(flat_out)
112
+
113
+
114
+ def mul_jac_hessian_jac(fn: ForwardFn, args: FwdLaplArgs, shared_idx: Array | None):
115
+ # For a dot product we know that the hessian looks like this:
116
+ # [0, I]
117
+ # [I, 0]
118
+ # where I is the identity matrix of the same shape as the input.
119
+ assert len(args) == 2, "Dot product only supports two args."
120
+ flat_out = (
121
+ 2 * trace_jac_jacT(args.arrays[0].jacobian, args.arrays[1].jacobian, shared_idx)[None]
122
+ )
123
+ unravel = jfu.ravel_pytree(fn(*args.x))[1]
124
+ return unravel(flat_out)
125
+
126
+
127
+ def remove_fill(arrs: np.ndarray, find_unique: bool = False):
128
+ """
129
+ Remove the fill value from an array. As the tensors might not be shaped correctly
130
+ afterwards, we reduce all the leading dimensions by lists.
131
+
132
+ Args:
133
+ - arrs: array to remove fill value from
134
+ Returns:
135
+ - arrs: nested lists of arrays without fill value
136
+ """
137
+ if arrs.size == 0:
138
+ return arrs
139
+ if arrs[0].ndim >= 1:
140
+ return [remove_fill(x, find_unique=find_unique) for x in arrs]
141
+ if find_unique:
142
+ arrs = np.unique(arrs)
143
+ return arrs[arrs >= 0] # type: ignore
144
+
145
+
146
+ def merge_and_populate(arrs: Sequence[np.ndarray], operation: Callable[[np.ndarray, np.ndarray], np.ndarray]):
147
+ """
148
+ The arrays are assumed to be of the same shape. We look at the intersection of all arrays.
149
+ We then find the maximum intersection size and fill all arrays to that size.
150
+
151
+ Args:
152
+ - arrs: list of arrays
153
+ Returns:
154
+ - arrs: np.ndarray where only intersections are kept and all arrays are filled to the same size.
155
+ """
156
+ result = jtu.tree_map(
157
+ lambda *x: functools.reduce(operation, tuple(x[1:]), x[0]),
158
+ *arrs,
159
+ is_leaf=lambda x: isinstance(x, np.ndarray)
160
+ )
161
+ sizes = jtu.tree_map(lambda x: x.size, result, is_leaf=lambda x: isinstance(x, np.ndarray))
162
+ max_size = np.max(jtu.tree_leaves(sizes))
163
+ result = jtu.tree_map(
164
+ lambda x: np.concatenate([x, np.full(max_size - x.size, -1, dtype=x.dtype)]),
165
+ result,
166
+ is_leaf=lambda x: isinstance(x, np.ndarray),
167
+ )
168
+ return np.asarray(result, dtype=int)
169
+
170
+
171
+ def find_materialization_idx(lapl_args: FwdLaplArgs, in_axes, flags: FunctionFlags, threshold: int):
172
+ if not lapl_args.any_jacobian_weak:
173
+ return None
174
+ # TODO: Rewrite this!! This is quity messy and inefficient.
175
+ # it assumes that we're only interested in the last dimension.
176
+ with core.new_main(core.EvalTrace, dynamic=True):
177
+ vmap_seq, (inp,) = vmap_sequences_and_squeeze(
178
+ ([j.mask for j in lapl_args.jacobian],),
179
+ ([j for j in add_vmap_jacobian_dim(lapl_args, FwdLaplArgs(in_axes)).jacobian],),
180
+ )
181
+ max_size = np.max([np.sum(j.unique_idx >= 0, dtype=int) for j in lapl_args.jacobian])
182
+ # This can be quite memory intensive, so we try to do it on the GPU and
183
+ # if that fails we just use the CPU. On the CPU this takes quite some time.
184
+ # TODO: work on a more memory efficient implementation!
185
+ unique_fn = functools.partial(jnp.unique, size=max_size + 1, fill_value=-1)
186
+
187
+ def idx_fn(x):
188
+ return jtu.tree_map(unique_fn, x)
189
+ for s in vmap_seq[::-1]:
190
+ idx_fn = jax.vmap(idx_fn, in_axes=s)
191
+ try:
192
+ # This path is more memory intensive by using the GPU to find uniques but
193
+ # potentially fails if the arrays are too large.
194
+ # +1 because we need to accomodate the -1.
195
+ arrs = np.asarray(idx_fn(inp), dtype=int)
196
+ except jaxlib.xla_extension.XlaRuntimeError:
197
+ logging.info(
198
+ "Failed to find unique elements on GPU, falling back to CPU. This will be slow."
199
+ )
200
+ with jax.default_device(jax.devices("cpu")[0]):
201
+ arrs = np.asarray(idx_fn(inp), dtype=int)
202
+ filtered_arrs = remove_fill(arrs, False)
203
+
204
+ if FunctionFlags.LINEAR_IN_ONE in flags:
205
+ # For off diagonal Hessians we only need to look at the intersection between
206
+ # all arrays rather than their union.
207
+ idx = merge_and_populate(filtered_arrs, np.intersect1d) # type: ignore
208
+ else:
209
+ idx = merge_and_populate(filtered_arrs, np.union1d) # type: ignore
210
+ idx = np.moveaxis(idx, -1, JAC_DIM)
211
+
212
+ if idx.shape[JAC_DIM] >= max_size or idx.shape[JAC_DIM] > threshold:
213
+ idx = None
214
+ return idx
215
+
216
+
217
+ def remove_zero_entries(lapl_args: FwdLaplArgs, materialize_idx: np.ndarray | None):
218
+ if materialize_idx is None:
219
+ return lapl_args, None, None
220
+
221
+ mask = (materialize_idx != -1).any(0)
222
+ if mask.sum() > 0.5 * mask.size:
223
+ # this is a heuristic to avoid having unnecessary indexing overhead for
224
+ # insufficiently sparse masks.
225
+ return lapl_args, materialize_idx, None
226
+
227
+ indices = np.where(mask)
228
+ new_mat_idx = materialize_idx[(slice(None), *indices)]
229
+ new_arrs = []
230
+ for arg in lapl_args.arrays:
231
+ brdcast_dims = np.where(np.array(arg.x.shape) == 1)[0]
232
+ idx = tuple(
233
+ 0 if i in brdcast_dims else x
234
+ for i, x in enumerate(indices)
235
+ )
236
+ new_arrs.append(FwdLaplArray(
237
+ x=arg.x[idx],
238
+ jacobian=FwdJacobian(
239
+ data=arg.jacobian.data[(slice(None), *idx)],
240
+ x0_idx=arg.jacobian.x0_idx[(slice(None), *idx)], # type: ignore
241
+ ),
242
+ laplacian=arg.laplacian[idx],
243
+ ))
244
+ new_args = FwdLaplArgs(tuple(new_arrs))
245
+ return new_args, new_mat_idx, mask
246
+
247
+
248
+ def vmapped_jac_hessian_jac(
249
+ fwd: ForwardFn,
250
+ flags: FunctionFlags,
251
+ custom_jac_hessian_jac: CustomTraceJacHessianJac | None,
252
+ extra_args: ExtraArgs,
253
+ in_axes: Axes,
254
+ extra_in_axes: Axes,
255
+ merge: MergeFn,
256
+ sparsity_threshold: int,
257
+ lapl_args: FwdLaplArgs,
258
+ ) -> PyTree[Array]:
259
+ # Determine output structure
260
+ def merged_fn(*x: Array):
261
+ return fwd(*merge(x, extra_args))
262
+ out = merged_fn(*lapl_args.x)
263
+ unravel = jfu.ravel_pytree(out)[1]
264
+
265
+ materialize_idx = find_materialization_idx(lapl_args, in_axes, flags, sparsity_threshold)
266
+ if materialize_idx is None:
267
+ lapl_args = lapl_args.dense
268
+ if materialize_idx is not None and materialize_idx.shape[JAC_DIM] == 0:
269
+ return jnp.zeros(())
270
+
271
+ # If we do a dot product (not a hadamard product) we can check for empty hessian entries
272
+ if FunctionFlags.DOT_PRODUCT in flags and all(len(a) == 1 for a in in_axes):
273
+ lapl_args, materialize_idx, mask = remove_zero_entries(lapl_args, materialize_idx)
274
+ in_axes = jtu.tree_map(lambda _: -1, in_axes)
275
+ else:
276
+ mask = None
277
+
278
+ # Broadcast and flatten all arguments
279
+ vmap_seq, (lapl_args, extra_args) = vmap_sequences_and_squeeze(
280
+ (lapl_args, extra_args),
281
+ (add_vmap_jacobian_dim(lapl_args, FwdLaplArgs(in_axes)), extra_in_axes),
282
+ )
283
+ # Hessian computation
284
+ def hess_transform(args: FwdLaplArgs, extra_args: ExtraArgs, materialize_idx):
285
+ def merged_fn(*x):
286
+ return fwd(*merge(x, extra_args))
287
+
288
+ if custom_jac_hessian_jac is not None:
289
+ result = custom_jac_hessian_jac(args, extra_args, merge, materialize_idx)
290
+ elif FunctionFlags.MULTIPLICATION in flags:
291
+ result = mul_jac_hessian_jac(merged_fn, args, materialize_idx)
292
+ elif FunctionFlags.LINEAR_IN_ONE in flags:
293
+ result = off_diag_jac_hessian_jac(merged_fn, args, materialize_idx)
294
+ else:
295
+ result = general_jac_hessian_jac(merged_fn, args, materialize_idx)
296
+ return result
297
+
298
+ # TODO: this implementation also assumes that we only reduce the last dimension.
299
+ for axes in vmap_seq[::-1]:
300
+ hess_transform = jax.vmap(
301
+ hess_transform, in_axes=(*axes, (None if materialize_idx is None else 1))
302
+ )
303
+ # flatten to 1D and then unravel to the original structure
304
+ if mask is None:
305
+ flat_out = jfu.ravel_pytree(hess_transform(lapl_args, extra_args, materialize_idx))[0]
306
+ else:
307
+ flat_out = hess_transform(lapl_args, extra_args, materialize_idx)
308
+ result = jnp.zeros_like(out).at[mask].set(flat_out) # type: ignore
309
+ flat_out = jfu.ravel_pytree(result)[0]
310
+ return unravel(flat_out)
311
+
312
+
313
+ def get_jacobian_hessian_jacobian_trace(
314
+ fwd: ForwardFn,
315
+ flags: FunctionFlags,
316
+ custom_jac_hessian_jac: CustomTraceJacHessianJac | None,
317
+ extra_args: ExtraArgs,
318
+ in_axes: Axes,
319
+ extra_in_axes: Axes,
320
+ merge: MergeFn,
321
+ ):
322
+ def hessian_transform(args: FwdLaplArgs, sparsity_threshold: int):
323
+ if FunctionFlags.LINEAR in flags:
324
+ return jnp.zeros(())
325
+ elif FunctionFlags.LINEAR_IN_ONE in flags and len(args.arrays) == 1:
326
+ return jnp.zeros(())
327
+ elif (
328
+ FunctionFlags.LINEAR_IN_FIRST in flags
329
+ and jtu.tree_leaves(merge(args.x, extra_args))[0] is args.x[0]
330
+ and len(args.arrays) == 1
331
+ ):
332
+ return jnp.zeros(())
333
+ else:
334
+ return vmapped_jac_hessian_jac(
335
+ fwd=fwd,
336
+ flags=flags,
337
+ custom_jac_hessian_jac=custom_jac_hessian_jac,
338
+ extra_args=extra_args,
339
+ in_axes=in_axes,
340
+ extra_in_axes=extra_in_axes,
341
+ merge=merge,
342
+ sparsity_threshold=sparsity_threshold,
343
+ lapl_args=args,
344
+ )
345
+
346
+ return hessian_transform
folx/interpreter.py ADDED
@@ -0,0 +1,221 @@
1
+ import logging
2
+ from collections import defaultdict
3
+ from typing import Callable, ParamSpec, Sequence, TypeVar
4
+
5
+ import jax
6
+ import jax.numpy as jnp
7
+ import jax.tree_util as jtu
8
+ import numpy as np
9
+ from jax import core
10
+ from jax.util import safe_map
11
+
12
+ from .api import Array, ArrayOrFwdLaplArray, FwdJacobian, FwdLaplArray, PyTree
13
+ from .utils import extract_jacobian_mask, ravel
14
+ from .wrapped_functions import get_laplacian
15
+
16
+ R = TypeVar("R", bound=PyTree[Array])
17
+ P = ParamSpec("P")
18
+
19
+
20
+ class JaxExprEnvironment:
21
+ # A simple environment that keeps track of the variables
22
+ # and frees them once they are no longer needed.
23
+ env: dict[core.Var, ArrayOrFwdLaplArray]
24
+ reference_counter: dict[core.Var, int]
25
+
26
+ def __init__(self, jaxpr: core.Jaxpr, consts: Sequence[Array], *args: ArrayOrFwdLaplArray):
27
+ self.env = {}
28
+ self.reference_counter = defaultdict(int)
29
+ for v in jaxpr.invars + jaxpr.constvars:
30
+ if isinstance(v, core.Literal):
31
+ continue
32
+ self.reference_counter[v] += 1
33
+ eqn: core.JaxprEqn
34
+ for eqn in jaxpr.eqns:
35
+ for v in eqn.invars:
36
+ if isinstance(v, core.Literal):
37
+ continue
38
+ self.reference_counter[v] += 1
39
+ for v in jaxpr.outvars:
40
+ if isinstance(v, core.Literal):
41
+ continue
42
+ self.reference_counter[v] = np.iinfo(np.int32).max
43
+ self.write_many(jaxpr.constvars, consts)
44
+ self.write_many(jaxpr.invars, args)
45
+
46
+ def read(self, var: core.Atom) -> ArrayOrFwdLaplArray:
47
+ if isinstance(var, core.Literal):
48
+ return var.val
49
+ self.reference_counter[var] -= 1
50
+ result = self.env[var]
51
+ if self.reference_counter[var] == 0:
52
+ del self.env[var]
53
+ del self.reference_counter[var]
54
+ return result
55
+
56
+ def write(self, var: core.Var, val: ArrayOrFwdLaplArray):
57
+ if self.reference_counter[var] > 0:
58
+ self.env[var] = val
59
+
60
+ def read_many(self, vars: Sequence[core.Atom]) -> list[ArrayOrFwdLaplArray]:
61
+ return safe_map(self.read, vars)
62
+
63
+ def write_many(self, vars: Sequence[core.Var], vals: Sequence[ArrayOrFwdLaplArray]):
64
+ return safe_map(self.write, vars, vals)
65
+
66
+
67
+ def eval_jaxpr_with_forward_laplacian(jaxpr: core.Jaxpr, consts, *args, sparsity_threshold: int):
68
+ enable_sparsity = sparsity_threshold > 0
69
+ env = JaxExprEnvironment(jaxpr, consts, *args)
70
+
71
+ def eval_scan(eqn: core.JaxprEqn, invals):
72
+ n_carry, n_const = eqn.params["num_carry"], eqn.params["num_consts"]
73
+ in_const, in_carry, in_inp = invals[:n_const], invals[n_const:n_carry+n_const], invals[n_const+n_carry:]
74
+ carry_merge = extract_jacobian_mask(in_carry)
75
+ assert all(isinstance(x, Array) for x in in_inp), "Scan does not support scanning over input depenedent tensors.\nPlease unroll the loop."
76
+ def wrapped(carry, x):
77
+ result = eval_jaxpr_with_forward_laplacian(
78
+ eqn.params['jaxpr'].jaxpr,
79
+ (),
80
+ *in_const,
81
+ *carry_merge(carry),
82
+ *x,
83
+ sparsity_threshold=sparsity_threshold
84
+ )
85
+ return result[:n_carry], result[n_carry:]
86
+ first_carry, first_y = wrapped(in_carry, jtu.tree_map(lambda x: x[0], in_inp))
87
+ # Check whether jacobian sparsity matches
88
+ for a, b in zip(in_carry, first_carry):
89
+ if type(a) != type(b):
90
+ raise TypeError(f"Type mismatch in scan: {type(a)} != {type(b)}")
91
+ if isinstance(a, FwdLaplArray):
92
+ if not np.all(a.jacobian.x0_idx == b.jacobian.x0_idx): # type: ignore
93
+ raise ValueError("Jacobian sparsity mismatch in scan.")
94
+ carry, y = jax.lax.scan(
95
+ wrapped, # type: ignore
96
+ in_carry,
97
+ in_inp,
98
+ length=eqn.params['length'],
99
+ reverse=eqn.params['reverse'],
100
+ unroll=eqn.params['unroll'],
101
+ )
102
+ carry = [
103
+ a._replace(jacobian=a.jacobian._replace(x0_idx=b.jacobian.x0_idx)) # type: ignore
104
+ if isinstance(a, FwdLaplArray) else a
105
+ for a, b in zip(carry, first_carry)
106
+ ]
107
+ y = [
108
+ a._replace(jacobian=a.jacobian._replace(x0_idx=b.jacobian.x0_idx)) # type: ignore
109
+ if isinstance(a, FwdLaplArray) else a
110
+ for a, b in zip(y, first_y)
111
+ ]
112
+ return *carry, *y
113
+
114
+ def eval_pjit(eqn: core.JaxprEqn, invals):
115
+ name = eqn.params["name"]
116
+ if fn := get_laplacian(name):
117
+ # TODO: this is a bit incomplete, e.g., kwargs?
118
+ outvals = fn(*invals, sparsity_threshold=sparsity_threshold)
119
+ if isinstance(outvals, (FwdLaplArray, Array)):
120
+ outvals = [outvals] # TODO: Figure out how to properly handle outvals
121
+ return outvals
122
+ sub_expr: core.ClosedJaxpr = eqn.params["jaxpr"]
123
+ return eval_jaxpr_with_forward_laplacian(
124
+ sub_expr.jaxpr, sub_expr.literals, *invals, sparsity_threshold=sparsity_threshold
125
+ )
126
+
127
+ def eval_laplacian(eqn: core.JaxprEqn, invals):
128
+ fn = get_laplacian(eqn.primitive, True)
129
+ return fn(*invals, **eqn.params, sparsity_threshold=sparsity_threshold)
130
+
131
+ for eqn in jaxpr.eqns:
132
+ invals = env.read_many(eqn.invars)
133
+ # Eval expression
134
+ if all(not isinstance(x, FwdLaplArray) for x in invals):
135
+ subfuns, bind_params = eqn.primitive.get_bind_params(eqn.params)
136
+ # If non of the inputs were dependent on an FwdLaplArray,
137
+ # we can just use the regular primitive. This will avoid
138
+ # omnistaging. While this could cost us some memory and speed,
139
+ # it gives us access to more variables during tracing.
140
+ # https://github.com/google/jax/pull/3370
141
+ if all(not isinstance(x, core.Tracer) for x in invals) and enable_sparsity:
142
+ try:
143
+ with core.new_main(core.EvalTrace, dynamic=True):
144
+ outvals = eqn.primitive.bind(*subfuns, *invals, **bind_params)
145
+ except Exception as e:
146
+ logging.warning(
147
+ f"Could not perform operation {eqn.primitive.name} in eager execution despite it only depending on non-input dependent values. "
148
+ "We switch to tracing rather than eager execution. This may impact sparsity propagation.\n"
149
+ f"{e}"
150
+ )
151
+ outvals = eqn.primitive.bind(*subfuns, *invals, **bind_params)
152
+ else:
153
+ outvals = eqn.primitive.bind(*subfuns, *invals, **bind_params)
154
+ elif eqn.primitive.name == "scan":
155
+ outvals = eval_scan(eqn, invals)
156
+ elif eqn.primitive.name == "pjit":
157
+ outvals = eval_pjit(eqn, invals)
158
+ else:
159
+ outvals = eval_laplacian(eqn, invals)
160
+
161
+ # unify output
162
+ if not eqn.primitive.multiple_results:
163
+ outvals = [outvals]
164
+ # save output
165
+ env.write_many(eqn.outvars, outvals) # type: ignore
166
+ return env.read_many(jaxpr.outvars)
167
+
168
+
169
+
170
+ def init_forward_laplacian_state(*x: PyTree[Array], sparsity: bool) -> PyTree[FwdLaplArray]:
171
+ """
172
+ Initialize forward Laplacian state from a PyTree of arrays.
173
+ """
174
+ x_flat, unravel = ravel(x)
175
+ jac = jtu.tree_map(jnp.ones_like, x)
176
+ jac_idx = unravel(np.arange(x_flat.shape[0]))
177
+ if sparsity:
178
+ jac = jtu.tree_map(lambda j, i: FwdJacobian(j[None], np.array(i)[None]), jac, jac_idx)
179
+ else:
180
+ jac = jax.vmap(unravel)(jnp.eye(len(x_flat)))
181
+ jac = jtu.tree_map(FwdJacobian.from_dense, jac)
182
+ lapl_x = jtu.tree_map(jnp.zeros_like, x)
183
+ return jtu.tree_map(FwdLaplArray, x, jac, lapl_x)
184
+
185
+
186
+ def forward_laplacian(
187
+ fn: Callable[P, PyTree[Array]],
188
+ sparsity_threshold: int | float = 0,
189
+ disable_jit: bool = False
190
+ ) -> Callable[P, PyTree[FwdLaplArray]]:
191
+ """
192
+ This function takes a function and returns a function that computes the Laplacian of the function.
193
+ The returned function will be jitted by default as running it in eager execution will typically be a lot slower.
194
+
195
+ Args:
196
+ - fn: function to compute the Laplacian of
197
+ - sparsity_threshold: threshold for sparsity propagation.
198
+ If the number of non-zero elements in the input is larger than this threshold,we will not propagate sparsity.
199
+ If the value is between 0 and 1, it will be interpreted as a fraction of the total number of elements.
200
+ If the value is larger than 1, it will be interpreted as an absolute number of elements.
201
+ If enabling sparsity, we recommend relatively large values like 0.6 as frequent materializations are slow.
202
+ """
203
+ def wrapped(*args: P.args, **kwargs: P.kwargs):
204
+ closed_jaxpr = jax.make_jaxpr(fn)(*args, **kwargs)
205
+ flat_args = jtu.tree_leaves(args)
206
+ if 0 < sparsity_threshold < 1:
207
+ threshold = int(sparsity_threshold * sum(x.size for x in flat_args))
208
+ else:
209
+ threshold = int(sparsity_threshold)
210
+ lapl_args = init_forward_laplacian_state(*flat_args, sparsity=threshold > 0)
211
+ out = eval_jaxpr_with_forward_laplacian(
212
+ closed_jaxpr.jaxpr, closed_jaxpr.literals, *lapl_args, sparsity_threshold=threshold
213
+ )
214
+ if len(out) == 1:
215
+ return out[0]
216
+ return out
217
+
218
+ if disable_jit:
219
+ return wrapped
220
+
221
+ return jax.jit(wrapped) # type: ignore