Skip to content

Commit 4d3691d

Browse files
committed
address review comments
1 parent 6d36d30 commit 4d3691d

7 files changed

Lines changed: 135 additions & 212 deletions

File tree

examples/vectoradd_jax/tesseract_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing_extensions import Self
1111

1212
from tesseract_core.runtime import Array, Differentiable, Float32
13-
from tesseract_core.runtime.gradient_recipes import (
13+
from tesseract_core.runtime.gradient_endpoints.jax_recipes import (
1414
jax_abstract_eval,
1515
jax_apply,
1616
jax_jacobian,

tesseract_core/runtime/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
continue
1515
if not (path.parent / "__init__.py").exists():
1616
continue
17-
# gradient_recipes/ is an opt-in plug-in surface — each recipe brings
18-
# its own framework dep (e.g. jax/equinox). Tesseracts that opt into
19-
# a recipe install its deps via tesseract_requirements.txt; tesseracts
20-
# that don't shouldn't be required to.
21-
if "gradient_recipes" in path.parts:
17+
# gradient_endpoints/ is an opt-in plug-in surface — each backend
18+
# (jax_recipes, future torch_recipes, ...) brings its own framework
19+
# dep. Tesseracts that opt into a backend install its deps via
20+
# tesseract_requirements.txt; tesseracts that don't shouldn't be
21+
# required to.
22+
if "gradient_endpoints" in path.parts:
2223
continue
2324

2425
package_path = ".".join(

tesseract_core/runtime/gradient_recipes/jax.py renamed to tesseract_core/runtime/gradient_endpoints/jax_recipes/__init__.py

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,21 @@
66
These functions remove the boilerplate from a JAX-backed Tesseract
77
``tesseract_api.py`` by providing one-line implementations of the
88
``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-
By default :func:`jax_apply` runs ``jax.vjp`` internally and stashes the
15-
resulting backward function in :data:`jax_vjp_cache`. A subsequent
16-
:func:`jax_vjp` call on the same inputs reuses that backward, skipping the
17-
redundant forward pass — typically a 10-20% saving on moderate-to-deep
18-
networks. Call :func:`set_jax_cache_size` with ``0`` to disable caching
19-
entirely; both :func:`jax_apply` and :func:`jax_vjp` then bypass the cache
20-
machinery and run their forward passes directly.
9+
``vector_jacobian_product`` and ``abstract_eval`` endpoints. The user
10+
supplies a single ``apply_jit`` (already wrapped in ``@eqx.filter_jit``);
11+
the helpers compose the JAX transforms around it.
12+
13+
VJP residual caching is enabled by default: :func:`jax_apply` stashes the
14+
backward function it constructs and :func:`jax_vjp` reuses it on the next
15+
matching call — typically a ~10-20% speedup on the standard tesseract-jax
16+
``apply → vjp`` pattern (which is hit by ``jax.value_and_grad``,
17+
``jax.jacrev``, and even plain ``jax.grad`` since tesseract-jax's
18+
``custom_vjp.fwd`` always runs ``apply()`` first). Call
19+
:func:`set_jax_vjp_cache_size` with ``0`` to disable. See its docstring
20+
for the full taxonomy.
2121
"""
2222

23+
import hashlib
2324
from collections.abc import Callable
2425
from typing import Any
2526

@@ -32,42 +33,90 @@
3233
LRUCache,
3334
filter_func,
3435
flatten_with_paths,
35-
hash_pytree_leaves,
3636
set_at_path,
3737
)
3838

39-
# Default size of the VJP residual cache. Use :func:`set_jax_cache_size` to
40-
# change at runtime; do not mutate this directly.
41-
_jax_cache_size = 1
42-
4339
#: Module-level VJP residual cache. ``jax_apply`` populates it; ``jax_vjp``
44-
#: reads from it.
45-
jax_vjp_cache = LRUCache(maxsize=_jax_cache_size)
46-
40+
#: reads from it. Set to ``None`` (via :func:`set_jax_vjp_cache_size` with
41+
#: ``0``) to disable caching — both helpers then bypass the cache machinery
42+
#: entirely.
43+
jax_vjp_cache: LRUCache | None = LRUCache(maxsize=1)
44+
45+
46+
def set_jax_vjp_cache_size(size: int) -> None:
47+
"""Enable or resize the VJP residual cache, discarding existing entries.
48+
49+
When :func:`jax_apply` and :func:`jax_vjp` are called in sequence on the
50+
same inputs (the typical pattern under tesseract-jax's ``custom_vjp``,
51+
including under ``jax.value_and_grad`` and ``jax.jacrev``), caching the
52+
backward function produced by :func:`jax_apply`'s internal ``jax.vjp``
53+
call lets the subsequent :func:`jax_vjp` skip the redundant forward
54+
pass. Typical speedup ~10-20% on moderate-to-deep networks.
55+
56+
Best fit:
57+
- tesseract-jax workflows that go through ``custom_vjp`` (every
58+
gradient evaluation runs ``apply()`` first via ``fwd``, so the
59+
cache reliably hits on the subsequent ``vjp()`` — true even
60+
under plain ``jax.grad``).
61+
- Manual ``apply()`` followed by multiple ``vjp()`` calls on the
62+
same inputs (e.g. iterative solvers, CG-style inverse problems).
63+
64+
Poor fit:
65+
- Very small models where the forward pass is microseconds: the
66+
cache machinery overhead exceeds the saved work.
67+
68+
Args:
69+
size: Number of cache slots. ``1`` (the default) covers the standard
70+
apply → vjp pattern. ``0`` disables caching entirely (both
71+
:func:`jax_apply` and :func:`jax_vjp` then bypass the cache
72+
machinery). Increase for workflows that interleave multiple
73+
``apply()`` calls before their corresponding ``vjp()`` calls.
74+
"""
75+
global jax_vjp_cache
76+
jax_vjp_cache = LRUCache(maxsize=size) if size > 0 else None
4777

48-
def set_jax_cache_size(size: int) -> None:
49-
"""Resize the VJP cache. Discards any existing entries."""
50-
global _jax_cache_size, jax_vjp_cache
51-
_jax_cache_size = size
52-
jax_vjp_cache = LRUCache(maxsize=size)
5378

79+
def hash_tree(tree: Any) -> bytes:
80+
"""Compute a SHA-256 digest over a pytree's structure and leaves.
5481
55-
def _hash_inputs(inputs_dict: dict) -> bytes:
56-
"""Compute a SHA-256 hash of a pytree of inputs for cache key comparison."""
57-
leaves, treedef = jax.tree.flatten(inputs_dict)
58-
return hash_pytree_leaves(leaves, treedef)
82+
Suitable for use as an :class:`LRUCache` key. For array leaves, the digest
83+
incorporates dtype + shape + raw bytes so leaves with identical bytes but
84+
different interpretations (e.g. ``int64[4]`` vs ``int64[2,2]``) don't
85+
collide. Scalar and primitive leaves use Python's ``hash()`` except for
86+
``str``/``bytes`` (whose hash is randomized across processes by Python's
87+
PYTHONHASHSEED — we encode them directly instead).
88+
"""
89+
leaves, treedef = jax.tree.flatten(tree)
90+
h = hashlib.sha256()
91+
h.update(str(treedef).encode())
92+
for leaf in leaves:
93+
if hasattr(leaf, "tobytes"):
94+
h.update(str(leaf.dtype).encode())
95+
h.update(str(leaf.shape).encode())
96+
h.update(leaf.tobytes())
97+
elif isinstance(leaf, (str, bytes)):
98+
h.update(leaf if isinstance(leaf, bytes) else leaf.encode())
99+
else:
100+
h.update(hash(leaf).to_bytes(8, "big", signed=True))
101+
return h.digest()
59102

60103

61104
def jax_apply(apply_jit: Callable, inputs: BaseModel) -> dict:
62-
"""Run ``apply_jit`` and populate the VJP cache.
105+
"""Run ``apply_jit`` and, if caching is enabled, populate the VJP cache.
106+
107+
``apply_jit`` is assumed to already be JIT-compiled (e.g. wrapped with
108+
``@eqx.filter_jit``); this helper does not jit it. The user-facing
109+
``apply`` endpoint may want to do pre/post-processing around the call,
110+
so we cannot wrap it in a jit internally.
63111
64-
A subsequent :func:`jax_vjp` call on the same input reuses the cached
65-
backward. When caching is disabled (see :func:`set_jax_cache_size`),
66-
this is just ``apply_jit(inputs.model_dump())``.
112+
When :data:`jax_vjp_cache` is set (see :func:`set_jax_vjp_cache_size`),
113+
the forward pass is run via ``jax.vjp`` so the resulting backward
114+
function can be stashed and reused by a later :func:`jax_vjp` call.
115+
Otherwise this is just ``apply_jit(inputs.model_dump())``.
67116
"""
68117
inputs_dict = inputs.model_dump()
69118

70-
if _jax_cache_size <= 0:
119+
if jax_vjp_cache is None:
71120
return apply_jit(inputs_dict)
72121

73122
# Compute forward pass via jax.vjp to cache residuals for a potential
@@ -85,7 +134,7 @@ def _apply_for_vjp(inputs_dict: dict) -> tuple:
85134
out = eqx.combine(diff_primals, static_primals)
86135

87136
cotangent_template = jax.tree.map(jnp.zeros_like, diff_primals)
88-
jax_vjp_cache.put(_hash_inputs(inputs_dict), (vjp_func, cotangent_template))
137+
jax_vjp_cache.put(hash_tree(inputs_dict), (vjp_func, cotangent_template))
89138
return out
90139

91140

@@ -98,8 +147,11 @@ def jax_vjp(
98147
) -> dict[str, Any]:
99148
"""Compute the vector-Jacobian product.
100149
101-
Reuses the cached backward from a prior :func:`jax_apply` call if one is
102-
available; otherwise falls through to a fresh ``jax.vjp`` evaluation.
150+
Reuses the cached backward from a prior :func:`jax_apply` call when one
151+
is available (see :func:`set_jax_vjp_cache_size`); otherwise falls
152+
through to a freshly JIT-compiled ``jax.vjp`` evaluation. The JIT
153+
compilation happens internally on the first miss for a given
154+
(input shape/dtype, path subset) combination and is cached for reuse.
103155
"""
104156
inputs_dict = inputs.model_dump()
105157

@@ -108,8 +160,8 @@ def jax_vjp(
108160
# value_and_grad is followed by jax.jacrev, which decomposes into many
109161
# vjp calls per output basis vector.
110162
if (
111-
_jax_cache_size > 0
112-
and (cached := jax_vjp_cache.get(_hash_inputs(inputs_dict))) is not None
163+
jax_vjp_cache is not None
164+
and (cached := jax_vjp_cache.get(hash_tree(inputs_dict))) is not None
113165
):
114166
vjp_func, cotangent_template = cached
115167
full_cotangent = jax.tree.map(jnp.zeros_like, cotangent_template)
@@ -134,7 +186,11 @@ def jax_jvp(
134186
jvp_outputs: set[str],
135187
tangent_vector: dict[str, Any],
136188
) -> dict[str, Any]:
137-
"""Compute the Jacobian-vector product via :func:`jax.jvp`."""
189+
"""Compute the Jacobian-vector product via :func:`jax.jvp`.
190+
191+
JIT compilation is applied internally and cached per
192+
``(input shape/dtype, jvp_inputs, jvp_outputs)`` combination.
193+
"""
138194
return _jvp_jit(
139195
apply_jit,
140196
inputs.model_dump(),
@@ -150,7 +206,11 @@ def jax_jacobian(
150206
jac_inputs: set[str],
151207
jac_outputs: set[str],
152208
) -> dict[str, dict[str, Any]]:
153-
"""Compute the Jacobian via :func:`jax.jacrev`."""
209+
"""Compute the Jacobian via :func:`jax.jacrev`.
210+
211+
JIT compilation is applied internally and cached per
212+
``(input shape/dtype, jac_inputs, jac_outputs)`` combination.
213+
"""
154214
return _jac_jit(
155215
apply_jit, inputs.model_dump(), tuple(jac_inputs), tuple(jac_outputs)
156216
)

tesseract_core/runtime/gradient_recipes/__init__.py

Lines changed: 0 additions & 32 deletions
This file was deleted.

tesseract_core/runtime/tree_transforms.py

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import collections
5-
import hashlib
65
import re
76
import threading
87
from collections.abc import Callable, Iterable, Mapping, Sequence
@@ -199,8 +198,7 @@ def put(self, key: bytes, value: Any) -> None:
199198
return
200199
with self._lock:
201200
if key in self._cache:
202-
if len(self._cache) > 1 and next(reversed(self._cache)) != key:
203-
self._cache.move_to_end(key)
201+
self._cache.move_to_end(key)
204202
self._cache[key] = value
205203
while len(self._cache) > self._maxsize:
206204
self._cache.popitem(last=False)
@@ -210,37 +208,11 @@ def get(self, key: bytes) -> Any | None:
210208
with self._lock:
211209
if key not in self._cache:
212210
return None
213-
if len(self._cache) > 1 and next(reversed(self._cache)) != key:
214-
self._cache.move_to_end(key)
211+
self._cache.move_to_end(key)
215212
return self._cache[key]
216213

217-
def pop(self, key: bytes) -> Any | None:
218-
"""Remove and return the value for *key*, or ``None`` on a miss."""
219-
with self._lock:
220-
return self._cache.pop(key, None)
221-
222214
@property
223215
def size(self) -> int:
224216
"""Return the number of entries currently in the cache."""
225217
with self._lock:
226218
return len(self._cache)
227-
228-
229-
def hash_pytree_leaves(leaves: Iterable, treedef: Any) -> bytes:
230-
"""Compute a SHA-256 digest over the leaves and structure of a pytree.
231-
232-
Args:
233-
leaves: Flat sequence of leaf values (arrays or scalars).
234-
treedef: Tree definition object (its ``str()`` is hashed to capture structure).
235-
236-
Returns:
237-
A 32-byte SHA-256 digest suitable for use as an :class:`LRUCache` key.
238-
"""
239-
h = hashlib.sha256()
240-
h.update(str(treedef).encode())
241-
for leaf in leaves:
242-
if hasattr(leaf, "tobytes"):
243-
h.update(leaf.tobytes())
244-
else:
245-
h.update(str(leaf).encode())
246-
return h.digest()

tesseract_core/sdk/templates/jax/tesseract_api.py

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,45 +10,23 @@
1010
from pydantic import BaseModel
1111

1212
from tesseract_core.runtime import Differentiable, Float32
13-
from tesseract_core.runtime.gradient_recipes import (
13+
from tesseract_core.runtime.gradient_endpoints.jax_recipes import (
1414
jax_abstract_eval,
1515
jax_apply,
1616
jax_jacobian,
1717
jax_jvp,
1818
jax_vjp,
1919
)
2020

21+
# VJP residual caching is enabled by default and gives a ~10-20% speedup on
22+
# the typical tesseract-jax apply → vjp pattern. See the docstring of
23+
# `set_jax_vjp_cache_size` for the full when-it-helps taxonomy. To disable
24+
# or resize, uncomment below:
2125
#
22-
# Cache configuration
23-
#
24-
# apply() runs jax.vjp internally and caches the resulting backward function.
25-
# A subsequent vector_jacobian_product call on the same inputs reuses that
26-
# backward, skipping the redundant forward pass that vjp would otherwise repeat.
27-
#
28-
# Under typical tesseract-jax usage (gradient-based code calling the Tesseract
29-
# through tesseract-jax's custom_vjp), apply() runs first on every gradient
30-
# evaluation — even under plain jax.grad — so the cache reliably hits on the
31-
# subsequent vjp call. This makes the recipe close to a free win for any
32-
# gradient-based workflow: Adam/SGD, L-BFGS / line search, value_and_grad-style
33-
# code, etc. Typical speedup is ~10-20% on moderate-to-deep networks.
34-
#
35-
# The cache also helps when a single apply is followed by multiple vjp calls
36-
# on the same input — for example, jax.jacrev (which decomposes into one vjp
37-
# per output basis vector) or CG-style inverse-problem solvers (each inner CG
38-
# step computes J^T u at the same iterate). Each subsequent vjp on the same
39-
# input reuses the cached residuals.
40-
#
41-
# Poor fit:
42-
# - Very small models where the forward pass is microseconds: the cache
43-
# machinery overhead exceeds the saved work.
44-
#
45-
# Uncomment to disable caching entirely (apply() and vjp() then bypass the
46-
# cache machinery and run their forward passes directly), or to increase the
47-
# size for workloads that interleave several apply() calls before their
48-
# corresponding vjp() calls.
49-
#
50-
# from tesseract_core.runtime.gradient_recipes import set_jax_cache_size
51-
# set_jax_cache_size(0)
26+
# from tesseract_core.runtime.gradient_endpoints.jax_recipes import (
27+
# set_jax_vjp_cache_size,
28+
# )
29+
# set_jax_vjp_cache_size(0) # disable
5230

5331
#
5432
# Schemata

0 commit comments

Comments
 (0)