66These 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
2324from collections .abc import Callable
2425from typing import Any
2526
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
61104def 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 )
0 commit comments