Use eagle with JAX#
You write jax.jit(jax.grad(...)) steps, vmap over batches and lax.scan over time.
This page changes one line of such a program: a step written in jax.numpy becomes a compiled hawk kernel.
Your loop, your transformations and your numbers stay the same.
It runs on the CPU and on a CUDA GPU alike; the stream cell needs a CUDA build of JAX.
Colab or Kaggle: run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports.
1. Your program as it is#
A ball-flight step with air drag, rolled out 20 steps for 256 balls, and a drag coefficient fitted by gradient descent in one jitted training step. This is plain JAX; nothing from eagle yet.
import os
os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false") # leave GPU memory for your other libraries
try: # CUDA 13: CuPy loads NVRTC before JAX does (see Limits)
import cupy
cupy.cuda.nvrtc.getVersion()
except Exception: # no CuPy or no CUDA here
pass
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True) # the kernel runs in float64 (see Limits for float32)
N, STEPS, DT, G = 256, 20, 0.05, 9.81
v0 = 5.0 * jax.random.normal(jax.random.key(0), (3, N))
def jax_step(v, cd, dt, g):
a = -cd * jnp.linalg.norm(v, axis=0) * v + jnp.array([0.0, 0.0, -g])[:, None]
return v + dt * a
def rollout(step, cd):
v = v0
for _ in range(STEPS):
v = step(v, cd, DT, G)
return v
target = rollout(jax_step, 0.07)
def fit(step):
loss = lambda cd: ((rollout(step, cd) - target) ** 2).mean()
@jax.jit
def train_step(cd):
value, grad = jax.value_and_grad(loss)(cd)
return cd - 2e-4 * grad, value
cd, losses = 0.01, []
for _ in range(60):
cd, value = train_step(cd)
losses.append(float(value))
return float(cd), losses
cd_jax, losses_jax = fit(jax_step)
print(f"jax step: cd = {cd_jax:.6f}, loss {losses_jax[0]:.4f} -> {losses_jax[-1]:.2e}")
jax step: cd = 0.070000, loss 5.1412 -> 1.60e-15
2. Drop the kernel in#
The same step as a hawk kernel, and the one line that turns it into a JAX function.
fit, rollout and the jitted train_step are not touched.
import hawk
from hawk import Mutable, Param, Vector
from hawk.math import norm, vec
@hawk.kernel
def drag_step(v: Vector[3], cd: Param, dt: Param, g: Param, v_next: Mutable[Vector[3]]):
a = vec(0.0, 0.0, -g) - cd * norm(v) * v
v_next = v + dt * a
from eagle.frameworks import jax as eagle_jax
step = eagle_jax.function(drag_step, wrt=("v", "cd")) # the one changed line
cd_eagle, losses_eagle = fit(step)
print(f"eagle step: cd = {cd_eagle:.6f}, loss {losses_eagle[0]:.4f} -> {losses_eagle[-1]:.2e}")
assert abs(cd_eagle - cd_jax) < 1e-9 and abs(losses_eagle[-1] - losses_jax[-1]) < 1e-9
print("same numbers as the jax step")
eagle step: cd = 0.070000, loss 5.1412 -> 1.60e-15
same numbers as the jax step
3. What stays the same#
Your transformations still work on the changed program. grad under jit, vmap of grad over a few drag
coefficients, and lax.scan over time, each against the jax.numpy step:
def loss_of(s):
return lambda cd: ((rollout(s, cd) - target) ** 2).mean()
def scanned(s):
return lambda cd: jax.lax.scan(lambda v, _: (s(v, cd, DT, G), None), v0, None, length=STEPS)[0]
cds = jnp.linspace(0.02, 0.1, 5)
for name, make in {
"jit(grad)": lambda s: jax.jit(jax.grad(loss_of(s))),
"vmap(grad)": lambda s: jax.vmap(jax.grad(loss_of(s))),
"lax.scan": scanned,
}.items():
arg = cds if name == "vmap(grad)" else 0.05
assert jnp.allclose(make(step)(arg), make(jax_step)(arg), rtol=1e-9, atol=1e-12)
print(f"{name:<11} matches the jax step")
jit(grad) matches the jax step
vmap(grad) matches the jax step
lax.scan matches the jax step
Jacobians and second derivatives come from the same derived kernels, in either mode. Forward mode also runs through a
kernel that carries state, and a kernel built with Guard(active_set=True) works in function() too
(tutorial 13 runs both):
final = lambda s: (lambda cd: rollout(s, cd)[:, 0]) # the first ball's final velocity
for name, transform in {"jacfwd": jax.jacfwd, "jacrev": jax.jacrev}.items():
assert jnp.allclose(transform(final(step))(0.05), transform(final(jax_step))(0.05), rtol=1e-9)
print(f"{name:<8} d velocity / d cd =", jnp.round(transform(final(step))(0.05), 4))
h_eagle, h_jax = jax.hessian(loss_of(step))(0.05), jax.hessian(loss_of(jax_step))(0.05)
assert jnp.allclose(h_eagle, h_jax, rtol=1e-9)
print(f"hessian d2 loss / d cd2 = {h_eagle:.4f} (jax step: {h_jax:.4f})")
jacfwd d velocity / d cd = [ 5.5486 55.3816 64.3769]
jacrev d velocity / d cd = [ 5.5486 55.3816 64.3769]
hessian d2 loss / d cd2 = 2351.1435 (jax step: 2351.1435)
4. What crosses, and who owns it#
JAX buffers cross without a copy: the kernel reads your inputs in place and writes into a buffer XLA allocated.
The result is an ordinary jax.Array, owned by JAX, and nothing you passed in is written to. A kernel that
finishes samples returns the updated Terminated mask last; hand the old mask in with donate_argnums and the
update happens in place.
from hawk import Terminated
@hawk.kernel(steps=1)
def land(r: Vector[3], terminated: Terminated, r_next: Mutable[Vector[3]]):
r_next = r - vec(0.0, 0.0, 1.0)
terminated = r_next[2] < 0.0
landing = eagle_jax.function(land, wrt=())
r = jnp.stack([jnp.zeros(8), jnp.zeros(8), jnp.arange(8.0) / 2])
done = jnp.zeros(8, dtype=bool)
r_in, done_in = jnp.array(r), jnp.array(done)
step_land = jax.jit(landing, donate_argnums=1)
mask_ptr = done.unsafe_buffer_pointer()
r_next, done_next = step_land(r, done)
print("result is a jax.Array:", isinstance(r_next, jax.Array), r_next.dtype, "| input untouched:", bool((r == r_in).all()))
print("mask updated in place:", done_next.unsafe_buffer_pointer() == mask_ptr, "| samples landed:", int(done_next.sum()))
result is a jax.Array: True float64 | input untouched: True
mask updated in place: True | samples landed: 2
On a GPU the launch rides XLA’s own stream, so it is ordered inside the jitted program and never waits for the host. Here a heavy matrix product runs first; the call still returns at once, and the kernel still reads its result:
import time
if jax.default_backend() == "gpu":
a = jnp.ones((4096, 4096), dtype=jnp.float32)
def heavy_then_step(a, v):
for _ in range(6):
a = a @ a / 4096.0
return step(v * a[0, 0], 0.05, DT, G) # a[0, 0] is 1.0 after the products
run = jax.jit(heavy_then_step)
run(a, v0).block_until_ready() # compile once
t0 = time.perf_counter()
out = run(a, v0)
t_dispatch = time.perf_counter() - t0
out.block_until_ready()
t_total = time.perf_counter() - t0
assert t_dispatch < 0.2 * t_total and jnp.allclose(out, jax_step(v0, 0.05, DT, G))
print("the call returned before the GPU finished; the result is still right")
else:
print("this JAX has no CUDA device: the stream check runs on a CUDA build")
this JAX has no CUDA device: the stream check runs on a CUDA build
A total over all samples#
A kernel can also reduce over its samples: hawk.sum(x) stored to a Mutable
is a cell, one value for the whole batch. The kernel writes one partial per 32 samples and the
bridge finishes them into that value inside the same XLA call, so the result is a (1,) array
that works under jit, grad and vmap like any other. jax.grad also works through a kernel
that reads its total later (a split kernel).
function(kernel, policy=...) chooses how that total is finished: "compensated" (the default), "ordered" or
"auto", as for eagle.reduce. Use "ordered" where the bits must be the same on every run; an unknown name is
refused. Integer sums, max and min are exact under any policy.
from hawk import Scalar
@hawk.kernel
def energy(x: Scalar, total: Mutable[Scalar]):
total = hawk.sum(x * x)
energy_of = eagle_jax.function(energy)
xs = jax.random.normal(jax.random.key(1), (1000,))
print("jit :", jax.jit(energy_of)(xs), " jnp:", (xs * xs).sum())
g = jax.jit(jax.grad(lambda a: energy_of(a).sum()))(xs)
assert jnp.allclose(g, 2 * xs)
print("grad matches 2x")
jit : [967.91464364] jnp: 967.9146436427735
grad matches 2x
5. What is refused, and why#
A kernel reads and writes exact buffers, so anything it cannot use as it is gets a named refusal.
def refused(label, call):
try:
call()
except Exception as err:
msg = str(err).splitlines()[0]
print(f"{label}: {type(err).__name__}: {msg if len(msg) <= 150 else msg[:147] + '...'}")
refused("mixed dtypes", lambda: step(v0, jnp.ones(1, dtype=jnp.float32), DT, G))
refused("integer plane", lambda: step(v0.astype(int), 0.05, DT, G))
refused("wrong shape", lambda: step(v0[:2], 0.05, DT, G))
refused("missing input", lambda: step(v0, 0.05, DT))
refused("not an input", lambda: step(v0, 0.05, DT, G, drag=1.0))
integer plane: ModeError: eagle.frameworks.jax drag_step: plane 'v' is int64, which is not supported; a kernel runs float32 or float64 planes
wrong shape: ValueError: eagle.frameworks.jax: drag_step: input 'v' must have shape (3, 256) for n=256, got (2, 256)
missing input: TypeError: drag_step: missing input(s) ['g']
not an input: TypeError: drag_step: 'drag' is not an input (inputs: ['v', 'cd', 'dt', 'g'])
6. Your JAX step inside an eagle batch#
The other direction: a jitted JAX function runs between eagle launches, over the same arrays. Here a JAX tanh feeds a
hawk kernel whose weights live in one array that your optimizer updates:
import tempfile
import warnings
warnings.filterwarnings("ignore", category=UserWarning, message=r"(?s).*CuPy may not function correctly.*")
import numpy as np
import eagle, eagle.diff, eagle.plan
import hawk
from eagle.graph import opaque
from eagle.interop import import_buffer
from hawk import Mutable, Scalar, Table
gpu = jax.default_backend() == "gpu"
xp = __import__("cupy") if gpu else np
@hawk.kernel
def lin(w: Table[Scalar], z: Scalar, y: Mutable[Scalar]):
y = w.at(0) * z + w.at(1) # w is ONE buffer, read by every sample
(plan,) = eagle.plan.auto([lin], targets=("cuda" if gpu else "host",), cache_dir=tempfile.mkdtemp())
x, z, y = xp.linspace(-1, 1, 8), xp.zeros(8), xp.zeros(8)
w = xp.array([2.0, 0.5]) # JAX arrays are immutable: one array of your own holds the weights
to_np = (lambda a: a.get()) if gpu else np.asarray
model = eagle_jax.node(jax.jit(lambda x: jnp.tanh(0.7 * x)), reads={"x": x}, writes={"z": z})
g = (eagle.Graph().add_node(model, name="model")
.add(plan.bind(w=w, z=z, y=y), name="lin").build(record=True))
w[:] = xp.from_dlpack(jnp.array([6.0, 1.5])) # the optimizer's new weights, copied in: the next launch reads them
g.launch()
print("a launch after the build sees the new weights:", bool(np.allclose(to_np(y), 6 * np.tanh(0.7 * to_np(x)) + 1.5)))
a launch after the build sees the new weights: True
node hands your planes to JAX without a copy, copies JAX’s results into z on its own stream, and keeps JAX’s
buffers alive until they have been used; the host never waits. On the CPU it is an eager call (the three tiers has the picture).
Your JAX code |
Write it as |
Runs |
|---|---|---|
a jitted step |
|
stream-ordered |
a step that reads a value back |
|
host-synchronized |
the whole batch inside |
the bridge above (sections 1 to 5) |
XLA’s own program |
JAX is never captured into an eagle graph, because XLA owns its allocations; a stepping loop that runs a JAX step is
therefore your own runner.run() with max_steps=k loop.
Weights. The Table is bound to one array of your own (w above); after each optimizer step copy the new value in.
Gradients. Build your vjp from jax.vjp of your step:
def vjp(primals, cotangents):
_, pull = jax.vjp(lambda a: jnp.tanh(0.7 * a), jax.dlpack.from_dlpack(xp.asarray(primals["x"]).copy()))
return {"x": xp.from_dlpack(pull(jax.dlpack.from_dlpack(xp.asarray(cotangents["z"]).copy()))[0])}
grads = eagle.diff.reverse(g.record, {"lin.y": xp.ones(8)}, vjps={"model": vjp})
want = 6 * 0.7 * (1 - np.tanh(0.7 * to_np(x)) ** 2)
print("d y / d x through your JAX step:", bool(np.allclose(to_np(grads["x"]), want)))
d y / d x through your JAX step: True
7. Limits#
What does not work today, one line each:
- A call uses one GPU: the one its arrays live on (plans, graphs and reductions follow their planes; a JAX function runs on the JAX device it was built under, one function per GPU). Arrays on two GPUs in one call are refused by name.
- A call runs the float32 or the float64 build, chosen by its first float plane, and mixing them is refused (the kernel is built once per dtype). JAX itself is float32 unless `jax_enable_x64` is on: cast every plane to one dtype, or turn x64 on as this page does.
- `vmap` over a batch of `Param` values is one launch over every entry; a kernel that reduces over samples itself, or has a `jump=` plan, runs one launch per entry instead (our scope): put the varying value in a plane for one launch.
- `jax.export` modules carry the kernel's digest, not its code (so they stay small and cache-friendly): in a fresh process call `eagle.frameworks.jax.preload(cache_dir)` before running one.
- `Terminated` is a stop decision, not a number, so it carries no derivative and a finished sample's derivatives are zero. Keep any soft version of the decision in `jnp`, and differentiate the planes.
- Needs `jax>=0.11.2`, the release whose XLA FFI version the bridge is built against (our scope); a newer jax works.
- JAX cannot run inside a captured eagle graph or stepping loop, because XLA owns its allocations (XLA's design): use `eagle.frameworks.jax.node` between launches, or put the batch inside `jit`.
- JAX leaves its last GPU current; eagle follows your arrays, so only NEW cupy allocations are affected (cupy allocates on the current device): make them inside `with cupy.cuda.Device(n):`.
- On CUDA 13, CuPy cannot compile a kernel after JAX has started, because JAX loads NVIDIA's NVRTC library first and that build of it cannot find its own builtins (NVIDIA's and JAX's packaging): call `cupy.cuda.nvrtc.getVersion()` before your first JAX call, as the notebooks here do.
- `jax.grad` does not differentiate through `lax.while_loop` (JAX's rule): use `lax.scan` with a next-state kernel.
- JAX runs under free-threading on Python 3.14t only, because jaxlib publishes no 3.13t wheel (JAX's release choice): use 3.14t for free-threaded JAX, or a regular 3.13 build.
8. Where next#
A hawk kernel inside your jitted training step: this page’s loop, taken further.
Batches and Jacobians and a stepping loop in
lax.scan, which also hands JAX arrays tountil_done.Simulated batches for a JAX training loop: eagle makes fresh data every step.
Interoperability: every framework, and the certification table that says what is checked end to end.
PyTorch, CuPy and NumPy and NVIDIA Warp: the same one-line change there.