|
| 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