Skip to content

Commit 817ba5f

Browse files
committed
add runtime.gradient_endpoints module and import from that
1 parent 1902129 commit 817ba5f

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Reusable implementations of gradient endpoints for Tesseract APIs.
5+
6+
The helpers in this package keep ``tesseract_api.py`` focused on the
7+
user's ``apply_jit`` and schemas by absorbing the boilerplate of the
8+
gradient endpoints (``apply``, ``jacobian``, ``jacobian_vector_product``,
9+
``vector_jacobian_product``, ``abstract_eval``).
10+
11+
See ``tesseract_core.runtime.gradient_recipes.jax`` for JAX-backed helpers.
12+
"""
13+
14+
from tesseract_core.runtime.gradient_recipes.jax import (
15+
JAX_CACHE_SIZE,
16+
jax_abstract_eval,
17+
jax_apply,
18+
jax_jacobian,
19+
jax_jvp,
20+
jax_vjp,
21+
jax_vjp_cache,
22+
set_jax_cache_size,
23+
)
24+
25+
__all__ = [
26+
"JAX_CACHE_SIZE",
27+
"jax_abstract_eval",
28+
"jax_apply",
29+
"jax_jacobian",
30+
"jax_jvp",
31+
"jax_vjp",
32+
"jax_vjp_cache",
33+
"set_jax_cache_size",
34+
]
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""JAX gradient endpoint helpers.
5+
6+
These functions remove the boilerplate from a JAX-backed Tesseract
7+
``tesseract_api.py`` by providing one-line implementations of the
8+
``apply``, ``jacobian``, ``jacobian_vector_product``,
9+
``vector_jacobian_product`` and ``abstract_eval`` endpoints, along with
10+
shared VJP residual caching across ``apply`` and ``vector_jacobian_product``.
11+
12+
Cache behaviour
13+
---------------
14+
When ``JAX_CACHE_SIZE > 0`` (the default), :func:`jax_apply` runs ``jax.vjp``
15+
internally and stashes the resulting backward function in :data:`jax_vjp_cache`.
16+
A subsequent :func:`jax_vjp` call on the same inputs reuses that backward,
17+
skipping the redundant forward pass — typically a 10-20% saving on
18+
moderate-to-deep networks. Set ``JAX_CACHE_SIZE = 0`` (or call
19+
:func:`set_jax_cache_size` with ``0``) to disable caching entirely; both
20+
``jax_apply`` and ``jax_vjp`` then bypass the cache machinery and run their
21+
forward passes directly.
22+
"""
23+
24+
from collections.abc import Callable
25+
from typing import Any
26+
27+
import equinox as eqx
28+
import jax
29+
import jax.numpy as jnp
30+
from pydantic import BaseModel
31+
32+
from tesseract_core.runtime.tree_transforms import (
33+
LRUCache,
34+
filter_func,
35+
flatten_with_paths,
36+
hash_pytree_leaves,
37+
set_at_path,
38+
)
39+
40+
#: Default size of the VJP residual cache. Override via
41+
#: :func:`set_jax_cache_size` to rebuild :data:`jax_vjp_cache` at a new size.
42+
JAX_CACHE_SIZE = 1
43+
44+
#: Module-level VJP residual cache. ``jax_apply`` populates it; ``jax_vjp``
45+
#: reads from it. Constructed at import using :data:`JAX_CACHE_SIZE`.
46+
jax_vjp_cache = LRUCache(maxsize=JAX_CACHE_SIZE)
47+
48+
49+
def set_jax_cache_size(size: int) -> None:
50+
"""Resize the VJP cache. Discards any existing entries."""
51+
global JAX_CACHE_SIZE, jax_vjp_cache
52+
JAX_CACHE_SIZE = size
53+
jax_vjp_cache = LRUCache(maxsize=size)
54+
55+
56+
def _hash_inputs(inputs_dict: dict) -> bytes:
57+
"""Compute a SHA-256 hash of a pytree of inputs for cache key comparison."""
58+
leaves, treedef = jax.tree.flatten(inputs_dict)
59+
return hash_pytree_leaves(leaves, treedef)
60+
61+
62+
def jax_apply(apply_jit: Callable, inputs: BaseModel) -> dict:
63+
"""Run ``apply_jit`` and populate the VJP cache.
64+
65+
A subsequent :func:`jax_vjp` call on the same input reuses the cached
66+
backward. With ``JAX_CACHE_SIZE <= 0`` this is just
67+
``apply_jit(inputs.model_dump())``.
68+
"""
69+
inputs_dict = inputs.model_dump()
70+
71+
if JAX_CACHE_SIZE <= 0:
72+
return apply_jit(inputs_dict)
73+
74+
# Compute forward pass via jax.vjp to cache residuals for a potential
75+
# subsequent vector_jacobian_product call. eqx.partition separates
76+
# array (differentiable) from non-array outputs; has_aux tells jax.vjp
77+
# to only differentiate through the array outputs.
78+
def _apply_for_vjp(inputs_dict: dict) -> tuple:
79+
out = apply_jit(inputs_dict)
80+
diff_out, static_out = eqx.partition(out, eqx.is_array)
81+
return diff_out, static_out
82+
83+
diff_primals, vjp_func, static_primals = jax.vjp(
84+
_apply_for_vjp, inputs_dict, has_aux=True
85+
)
86+
out = eqx.combine(diff_primals, static_primals)
87+
88+
cotangent_template = jax.tree.map(jnp.zeros_like, diff_primals)
89+
jax_vjp_cache.put(_hash_inputs(inputs_dict), (vjp_func, cotangent_template))
90+
return out
91+
92+
93+
def jax_vjp(
94+
apply_jit: Callable,
95+
inputs: BaseModel,
96+
vjp_inputs: set[str],
97+
vjp_outputs: set[str],
98+
cotangent_vector: dict[str, Any],
99+
) -> dict[str, Any]:
100+
"""Compute the vector-Jacobian product.
101+
102+
Reuses the cached backward from a prior :func:`jax_apply` call if one is
103+
available; otherwise falls through to a fresh ``jax.vjp`` evaluation.
104+
"""
105+
inputs_dict = inputs.model_dump()
106+
107+
# Use get (not pop) so the cached residuals can serve multiple sequential
108+
# vjp calls on the same inputs — for example, when tesseract-jax's
109+
# value_and_grad is followed by jax.jacrev, which decomposes into many
110+
# vjp calls per output basis vector.
111+
if (
112+
JAX_CACHE_SIZE > 0
113+
and (cached := jax_vjp_cache.get(_hash_inputs(inputs_dict))) is not None
114+
):
115+
vjp_func, cotangent_template = cached
116+
full_cotangent = jax.tree.map(jnp.zeros_like, cotangent_template)
117+
full_cotangent = set_at_path(full_cotangent, cotangent_vector)
118+
(all_input_cotangents,) = vjp_func(full_cotangent)
119+
return flatten_with_paths(all_input_cotangents, include_paths=vjp_inputs)
120+
121+
# Cache disabled or cache miss: fall back to original JIT-compiled path.
122+
return _vjp_jit(
123+
apply_jit,
124+
inputs_dict,
125+
tuple(vjp_inputs),
126+
tuple(vjp_outputs),
127+
cotangent_vector,
128+
)
129+
130+
131+
def jax_jvp(
132+
apply_jit: Callable,
133+
inputs: BaseModel,
134+
jvp_inputs: set[str],
135+
jvp_outputs: set[str],
136+
tangent_vector: dict[str, Any],
137+
) -> dict[str, Any]:
138+
"""Compute the Jacobian-vector product via :func:`jax.jvp`."""
139+
return _jvp_jit(
140+
apply_jit,
141+
inputs.model_dump(),
142+
tuple(jvp_inputs),
143+
tuple(jvp_outputs),
144+
tangent_vector,
145+
)
146+
147+
148+
def jax_jacobian(
149+
apply_jit: Callable,
150+
inputs: BaseModel,
151+
jac_inputs: set[str],
152+
jac_outputs: set[str],
153+
) -> dict[str, dict[str, Any]]:
154+
"""Compute the Jacobian via :func:`jax.jacrev`."""
155+
return _jac_jit(
156+
apply_jit, inputs.model_dump(), tuple(jac_inputs), tuple(jac_outputs)
157+
)
158+
159+
160+
def jax_abstract_eval(apply_jit: Callable, abstract_inputs: BaseModel) -> dict:
161+
"""Calculate the output shape of ``apply_jit`` from the shape of its inputs."""
162+
is_shapedtype_dict = lambda x: type(x) is dict and (x.keys() == {"shape", "dtype"})
163+
is_shapedtype_struct = lambda x: isinstance(x, jax.ShapeDtypeStruct)
164+
165+
jaxified_inputs = jax.tree.map(
166+
lambda x: jax.ShapeDtypeStruct(**x) if is_shapedtype_dict(x) else x,
167+
abstract_inputs.model_dump(),
168+
is_leaf=is_shapedtype_dict,
169+
)
170+
dynamic_inputs, static_inputs = eqx.partition(
171+
jaxified_inputs, filter_spec=is_shapedtype_struct
172+
)
173+
174+
def wrapped_apply(dynamic_inputs: dict) -> dict:
175+
inputs = eqx.combine(static_inputs, dynamic_inputs)
176+
return apply_jit(inputs)
177+
178+
jax_shapes = jax.eval_shape(wrapped_apply, dynamic_inputs)
179+
return jax.tree.map(
180+
lambda x: (
181+
{"shape": x.shape, "dtype": str(x.dtype)} if is_shapedtype_struct(x) else x
182+
),
183+
jax_shapes,
184+
is_leaf=is_shapedtype_struct,
185+
)
186+
187+
188+
#
189+
# Internal jit-compiled fallbacks (used on cache miss or when caching is disabled).
190+
#
191+
192+
193+
@eqx.filter_jit
194+
def _jac_jit(
195+
apply_jit: Callable,
196+
inputs: dict,
197+
jac_inputs: tuple[str, ...],
198+
jac_outputs: tuple[str, ...],
199+
) -> dict:
200+
filtered_apply = filter_func(apply_jit, inputs, jac_outputs)
201+
return jax.jacrev(filtered_apply)(
202+
flatten_with_paths(inputs, include_paths=jac_inputs)
203+
)
204+
205+
206+
@eqx.filter_jit
207+
def _jvp_jit(
208+
apply_jit: Callable,
209+
inputs: dict,
210+
jvp_inputs: tuple[str, ...],
211+
jvp_outputs: tuple[str, ...],
212+
tangent_vector: dict,
213+
) -> dict:
214+
filtered_apply = filter_func(apply_jit, inputs, jvp_outputs)
215+
return jax.jvp(
216+
filtered_apply,
217+
[flatten_with_paths(inputs, include_paths=jvp_inputs)],
218+
[tangent_vector],
219+
)[1]
220+
221+
222+
@eqx.filter_jit
223+
def _vjp_jit(
224+
apply_jit: Callable,
225+
inputs: dict,
226+
vjp_inputs: tuple[str, ...],
227+
vjp_outputs: tuple[str, ...],
228+
cotangent_vector: dict,
229+
) -> dict:
230+
filtered_apply = filter_func(apply_jit, inputs, vjp_outputs)
231+
_, vjp_func = jax.vjp(
232+
filtered_apply, flatten_with_paths(inputs, include_paths=vjp_inputs)
233+
)
234+
return vjp_func(cotangent_vector)[0]

0 commit comments

Comments
 (0)