← jax4jChapter 3 · Capturing and compiling functions6 min read

Your first Jit.compile

Capture a tensor expression, replay it with fresh values and watch which input changes create another cached plan.

Java notebookJava kernel · starts on first runWebGPU · not requested

Run cells from top to bottom. Typed declarations and imports carry into later cells. Shift + Enter runs a cell. Reloading or leaving this page discards edits and Java state.

Ready to start

The same calculation, fresh numbers

You need to evaluate -(x² + 3) repeatedly. On [1, 2, 3], the answer is [-4, -7, -12]. On [-1, 0, 4], it is [-4, -3, -19]. The arithmetic stays the same; only the input values change. This is a small, useful place to separate describing a tensor calculation from replaying its execution plan.

Jit.compile creates a callable object. Its first call for an input signature captures the tensor operations and returns their result. Compatible later calls replay the stored plan with their own input buffers. A signature describes the input's shape, dtype, device and backend. The actual numerical values remain runtime inputs.

Creating a compiled function leaves its cache empty. The first three-element input captures a plan and produces minus 4, minus 7, minus 12. Another three-element input replays that plan with new values and produces minus 4, minus 3, minus 19.

Choose Run all, then change individual inputs. The cells share an explicitly typed compiled-function variable in one Java session. This notebook uses the bundled jax4j 0.2.1 CPU backend. The browser JVM is emulated, so the examples teach semantics and cache behavior rather than native performance. Leaving or reloading the page discards edits, plans and session state.

1. Create the callable before supplying data

The lambda describes a square, scalar addition and negation. Its intermediate tensors are created through JIT's tracing backend, which manages them while building and executing the plan. We own the ordinary tensors passed in and the new output tensors returned from these examples.

Creating the callable alone does not capture an input-dependent plan. There is no input shape yet. Check that its cache starts empty.

In [ ]:

1. Create a compiled tensor function

Expect plans before input: 0. The compiled object implements UnaryOperator<Tensor>, so .apply(input) is the entry point you will use repeatedly. It is a jax4j tensor-program abstraction; the capture process records backend operations reached by the Java lambda.

Try this: change 3 to 5 and rerun setup. You have created a different compiled object with an empty cache. Its first answer on [1, 2, 3] will be [-6, -9, -14]. Restore 3 and rerun setup before following the reference cache counts below. Editing text alone does not execute the modified cell.

2. The first application captures and computes

The first input has shape [3]. The capture runs the Java body, records the calculation through the tracing backend and produces an output. You do not need a separate invocation to get the first numerical result.

In [ ]:

2. Supply the first input

Expect [-4, -7, -12] and one cached plan. blockUntilReady() is an explicit completion point, useful when reasoning about asynchronous devices. The later .toFloatArray() also needs a host-visible result. Neither call belongs inside the compiled lambda in these examples; reporting happens after the tensor computation has returned.

The eligible elementwise chain can be fused by the backend rather than launched operation by operation. Matrix products, reductions and other operations introduce their own execution boundaries. The existence of one cached plan does not tell you how many kernels it contains, nor guarantee a speedup on every backend or problem size.

3. New values reuse the same signature

The second input is a fresh tensor with different values but the same shape and placement. Those new values must influence the answer. Replaying a plan reuses the calculation, not the previous output numbers.

In [ ]:

3. Replay with different values

Expect [-4, -3, -19] and still one plan. Change the input to [2, 4, 6] and predict [-7, -19, -39]. The compiled function can outlive each of these individual input and output tensors. Once their use is complete, the try blocks close them; the next call supplies a fresh input handle.

The function is elementwise, so changing only one entry affects only the corresponding output entry. This makes a good first check when experimenting: keep the shape fixed, alter one recognizable number and confirm which output changes. A function involving a reduction or matrix product would connect positions differently, even with the same capture-and-replay mechanism.

4. A different shape creates another plan

A four-element input has another signature. This compiled object keeps a separate plan for it, and the Java body is captured for that new case. Returning to a previously seen signature can then reuse its original plan.

In [ ]:

4. Add a four-element signature

Expect [-4, -7, -12, -19] and two plans when running from the top. Rerunning this cell with the same shape does not add a third plan. Try five entries with shape [5], then return to four. Watch the count alongside the results so you can distinguish new numerical data from a new specialization.

The compiled object has separate plans for shape 3 and shape 4, even though both use F32 on the CPU backend. Numerical values do not distinguish these cache entries.

Cache counts are cumulative for this compiled object. Restarting the kernel or rerunning setup creates fresh state. If your count differs from the printed reference after an experiment, first ask which signatures that particular object has already seen. A notebook's execution history is part of the experiment, not an invisible reset between cells.

5. Revisit the first signature

We can now call the three-element case again. The values are different from both earlier three-element inputs, but the signature is familiar.

In [ ]:

5. Return to a cached shape

Expect [-3, -3, -3] and two plans in the unmodified sequence. The same array of six numbers could also be reshaped to [2, 3] or [3, 2]; those would be distinct signatures despite sharing the same element count. Predict the output shape for each, then try one by adding explicit dimensions to Tensors.array.

For this version, the compiled object exposes a plan count but no public cache clear or eviction control. Avoid feeding an unbounded variety of shapes into one long-lived callable without considering retained plans. Creating a new callable starts another cache; ordinary Java reachability then governs the old object's lifetime. A new plan is not itself evidence of an error.

6. Compile a derivative function too

The gradient from Chapter 2 is another tensor-to-tensor function. We can compose it with compilation. For sum(x²), the gradient is 2x, so [1, 2, 3] should produce [2, 4, 6]. This example keeps the calculation small enough to inspect without mixing in an optimizer or a training loop.

In [ ]:

6. Compile the gradient of a scalar loss

Try [-1, 0, 4], giving [-2, 0, 8]. This separate compiled object has its own cache; it does not share the polynomial object's plan count. The transform composition still needs derivative rules for every operation it encounters. Compilation does not make an unsupported derivative available.

Jit.apply is also available for a one-shot traced execution. The persistent object returned by Jit.compile adds reusable execution plans per signature. Keep that distinction in mind when reading examples that use the shorter one-shot form.

Continue with capture rules, cache keys and host reads before closing over mutable Java configuration. Revisit scalar gradients if you want to change the final loss and predict the compiled derivative.


Questions or corrections? open an issue