TensorFlow bridge¶
TensorFlow and JAX both hold an array as a typed buffer; the difference is the framework around it. A bridge converts a tensor from one side to the other, and lets a Dense-Armor model be a tf.keras.layers.Layer that trains under a Keras optimiser.
1. A tensor from one side to the other¶
to_jax(t) converts a tf.Tensor or a tf.Variable to a jax.Array, and to_tf(a) does the reverse. The conversion uses DLPack: TensorFlow 2.21 expects the capsule (a.__dlpack__()), not the jax array, so to_tf calls it explicitly. Both functions accept a pytree of tensors.
import tensorflow as tf
from dense_armor.bridges.tensorflow import to_jax, to_tf
t = tf.constant([1.0, 2.0, 3.0], dtype=tf.float32)
a = to_jax(t)
back = to_tf(a)
print(a.dtype, tuple(a.shape), bool(tf.reduce_all(t == back)))
The dtype is kept: a float32 tensor stays float32, no float64 is introduced. A non-contiguous tensor is exported through DLPack and comes out with the same values.
2. A Dense-Armor model as a Keras layer¶
KerasLayer(model) wraps a Dense-Armor Sequential as a tf.keras.layers.Layer. Every Dense-Armor Param becomes a tf.Variable, so a Keras optimiser sees it. call runs the JAX model through jax2tf.convert; the gradient of the input and of the weights is computed by TensorFlow's GradientTape through the converted function.
import tensorflow as tf, jax
from dense_armor.bridges.tensorflow import KerasLayer
from dense_armor.utility.deep.neural import Dense, Sequential
from dense_armor.utility.deep.neural.activations import Relu
model = Sequential([Dense(4, 8, key=jax.random.PRNGKey(0)), Relu(),
Dense(8, 3, key=jax.random.PRNGKey(1))])
layer = KerasLayer(model)
x = tf.zeros((2, 4), dtype=tf.float32)
print(tuple(layer(x).shape), len(layer.trainable_variables))
The gradient of the layer output with respect to the input equals jax.grad of the same JAX call, and a step of tf.keras.optimizers.SGD lowers the loss on a synthetic problem. Both are tested.
3. A Keras model inside Dense-Armor¶
from_keras(module) returns an adapter with apply(x) that runs the Keras model on x. For a jax array input the gradient is computed with tf.GradientTape and exposed to jax through jax.custom_vjp, so a Dense-Armor pipeline can back propagate through the adapter.
import tensorflow as tf
from dense_armor.bridges.tensorflow import from_keras
module = tf.keras.Sequential([
tf.keras.layers.Dense(8, activation="relu"),
tf.keras.layers.Dense(2)])
adapter = from_keras(module)
print(tuple(adapter.apply(tf.zeros((3, 4))).shape))
4. What the bridge costs¶
benchmarks/bridge_tensorflow.py measures the forward of the same model (a Sequential of Dense(64,128), Relu, Dense(128,32)) on batch 256, float32, seven runs: the JAX model called directly, an equivalent tf.keras.Sequential called directly, and the bridge.
The claim declared in the first commit (the bridge costs less than 20% of the JAX forward) does not hold. On batch 256 the bridge takes 10.5 ms against 0.19 ms of the JAX forward, a factor of 53; the TensorFlow model called directly takes 3.4 ms on the same data, so the bridge adds about 7 ms on top of TensorFlow's own cost. The jax2tf.convert of the forward is cached per (shape, training) inside the layer, so only the first call pays the conversion. The full run at batch 256 is in benchmarks/bridge_tensorflow.json. The cost is dominated by the TensorFlow layer machinery around the JAX call, not by the DLPack conversion; making it cheaper is a separate task.
Details¶
References¶
- TensorFlow 2.21,
tf.experimental.dlpack: https://www.tensorflow.org/api_docs/python/tf/experimental/dlpack. - TensorFlow 2.21,
tf.custom_gradient: https://www.tensorflow.org/api_docs/python/tf/custom_gradient. - TensorFlow 2.21,
tf.keras.Model: https://www.tensorflow.org/api_docs/python/tf/keras/Model. - JAX 0.11.1,
jax2tf: https://jax.readthedocs.io/en/latest/jax.experimental.jax2tf.html.
Choices stated explicitly¶
- DLPack with the capsule. TensorFlow 2.21
tf.experimental.dlpack.from_dlpackwants a DLPack capsule, not a jax array:to_tfcallsa.__dlpack__()explicitly. A jax array passed directly raises aTypeError. jax2tf.convertfor the layer, notcustom_gradient. The audit shows thatjax2tf.convertconvertsDenseandSequentialwithout error; the four activations fail only because theirforwardhas notrainingkeyword, which is not ajax2tflimitation.KerasLayerusesjax2tf.convertand lets TensorFlow'sGradientTapecompute the gradients, so the layer is differentiable end to end without a hand-written backward.- The weights are plain
tf.Variable, not Keras weights. A Kerasadd_weightreturns aKerasVariable, which is not a valid argument forjax2tf.convert; plaintf.Variableworks, and the layer exposes them throughtrainable_variables. from_kerasusestf.GradientTapewithtape.watch. The gradient of the Keras model with respect to a jax input is computed byGradientTape, and the tape mustwatchthe input explicitly (atf.Variablecreated insidepure_callbackis not watched automatically). The result matchesjax.gradof the same function.KerasLayer.calldeep-copies the Dense-Armor model at every call. The Dense-Armor layers hold mutableParam.value, so a shared model would leak the tracers of the previous call; a copy per call avoids the leak and costs about 1 ms. Thejax2tf.convertof the forward function is cached per(input shape, training flag), so only the first call pays the conversion; the remaining 53x overhead is the Keras layer machinery and the per-call copy. A functional wrapper is a separate task.KerasLayer.callpassestraining=Falseto the Dense-Armor model. ADropoutor aBatchNorminside the wrapped model stays in evaluation mode even when the Keras layer is intrain(). A full integration with Keras's training mode is a separate task.- The TensorFlow tests need
tensorflowinstalled. In CI the package is not in thedevextra, so the tests are skipped bypytest.importorskip("tensorflow")and codecov sees 0% ondense_armor/bridges/tensorflow.py. Installing the extra in the CI job is a separate change to the workflow, and the workflow file needs a token with theworkflowscope. - The output of
from_kerasis 2D ((batch, out_dim)). A model that returns a 3D tensor (aReshape, a small sequence) raises aJaxRuntimeError: theShapeDtypeStructin the callback is built frommodule.output_shape[-1]and the leading dimension isx.shape[0]. A general shape is a separate task. - The dtype is float32. A float64 input comes out as float32, because the callback builds the result with
dtype=jnp.float32and the TensorFlow module runs in float32. There is no warning; the bridge assumes the float32 default of the library. from_kerasworks underjax.jit. A fresh adapter called for the first time underjitworks: the output shape is probed with a batch-1 tensor built outside the trace, thenjax.pure_callbackhandles the callback under the trace.jit(grad(apply))works too.KerasLayerrespectstraining=True. The converted function is cached per(input shape, training flag), and withtraining=Truea new converted function is built with the flag on, so aDropoutinside the wrapped model applies a mask. A test checks thattraining=Truediffers fromtraining=False.- The optional dependency is
tensorflow-cpu. The package is smaller than the GPU wheel and the bridge is tested on the CPU; a reader with a GPU installstensorflowinstead.
Measured cost¶
The JSON is benchmarks/bridge_tensorflow.json. The claim does not hold and the delivery says so.
Development diary¶
The first version of KerasLayer used tf.custom_gradient with a jax vjp inside. TensorFlow 2.21 expects the gradient function of a custom_gradient that uses variables to return a pair (input_grads, variable_grads), and the exact shape of that contract differs between calls with and without variables; the version with jax2tf.convert and GradientTape is shorter and works for the classes the audit names.