Rune vs. JAX — A Practical Comparison

This guide explains how rune's transformations relate to JAX, focusing on:

  • How core concepts map (grad, vjp, jvp, vmap, pytrees, custom rules, remat)
  • Where the APIs feel similar vs. deliberately different
  • What rune does not have yet, stated honestly

If you already use JAX, this should be enough to become productive in rune quickly.


1. Big-Picture Differences

Aspect JAX (Python) rune (OCaml)
Language Dynamic, interpreted Statically typed, compiled
Array type jax.Array Nx.t (no separate rune tensor type)
Array library jax.numpy Nx
AD mechanism Tracing + XLA compilation OCaml 5 effect handlers, eager
Parameter containers Pytrees (registered runtime trees) Nx.Ptree.S — your own typed records
Reverse mode jax.grad, jax.value_and_grad grad, value_and_grad, _aux variants
VJP jax.vjp vjp, vjp_fun (reusable pullback), vjp2
Forward mode jax.jvp jvp, jvp_aux, jvp2
Vectorizing map jax.vmap vmap, vmap2, vmap'
Custom rules jax.custom_vjp, jax.custom_jvp custom_vjp, custom_jvp
Checkpointing jax.checkpoint / jax.remat remat
Jacobians / Hessians jacfwd, jacrev, hessian jacfwd', jacrev', hessian', hvp
Control flow lax.scan, lax.cond, lax.while_loop (required under jit) scan, cond, while_loop (optional, staging-ready) plus ordinary OCaml control flow
Gradient stopping jax.lax.stop_gradient detach, no_grad
Gradient checking jax.test_util.check_grads check_grads
Randomness Explicit splittable keys (jax.random) Implicit scoped RNG (Nx.Rng.run)
JIT compilation jax.jit Not yet implemented
Devices jax.device_put, GPU/TPU CPU only

2. Pytrees → Ptree.S

This is the deepest difference. JAX flattens arbitrary registered containers into lists of leaves at runtime:

import jax

params = {"w": w, "b": b}          # any registered pytree
grads = jax.grad(loss)(params)      # same pytree of gradients

Rune has no runtime tree. A parameter structure is a record you define, and you tell the library how to traverse its tensor leaves by implementing Nx.Ptree.S — three hand-written one-liners, no ppx, no registration table:

type params = { w : Nx.float32_t; b : Nx.float32_t }

module Params = struct
  type t = params

  let map (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t) { w; b } =
    { w = f w; b = f b }

  let map2 (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t -> ('a, 'b) Nx.t) p q =
    { w = f p.w q.w; b = f p.b q.b }

  let iter (f : 'a 'b. ('a, 'b) Nx.t -> unit) { w; b } =
    f w;
    f b
end

Every transformation takes the module as a first-class argument and preserves the type: the gradient of a function of params is a params. There is no tree_map because Params.map is tree_map, specialized to your type — and Rune.Ptree (that is, Nx.Ptree.t) is the stock dynamic instance for structures only known at runtime, the closest analogue of a raw pytree.

Where JAX distinguishes leaves by position in a flattened list, rune leaves keep their record field names, dtypes, and shapes in the type. Mixed dtypes work: a single backward pass produces a gradient for every leaf, each with its leaf's dtype.


3. Reverse Mode

JAX

def loss(params):
    return jnp.mean((x @ params["w"] + params["b"] - y) ** 2)

grads = jax.grad(loss)(params)
loss_value, grads = jax.value_and_grad(loss)(params)

rune

let () =
  let x = Nx.create Nx.float32 [| 4; 3 |] (Array.init 12 float_of_int) in
  let y = Nx.create Nx.float32 [| 4; 1 |] [| 0.; 1.; 2.; 3. |] in
  let loss p =
    Nx.mean (Nx.square (Nx.sub (Nx.add (Nx.matmul x p.w) p.b) y))
  in
  let params =
    { w = Nx.zeros Nx.float32 [| 3; 1 |]; b = Nx.zeros Nx.float32 [| 1 |] }
  in
  let grads = Rune.grad (module Params) loss params in
  let loss_value, grads' =
    Rune.value_and_grad (module Params) loss params
  in
  ignore (grads, loss_value, grads')

Both require a scalar output. JAX's argnums has no equivalent: differentiate with respect to the parameter structure and close over everything else. For a function of one tensor, grad' skips the module argument.

Auxiliary outputs

JAX uses a flag; rune has dedicated _aux variants:

(loss, aux), grads = jax.value_and_grad(f, has_aux=True)(params)
let loss, grads, aux = Rune.value_and_grad_aux (module Params) f params

4. VJP and JVP

JAX's jax.vjp returns a pullback closure; rune offers both that shape (vjp_fun) and a one-shot version (vjp) that takes the cotangent directly:

y, pullback = jax.vjp(f, x)
grads = pullback(ct)
let () =
  let f v = Nx.mul v v in
  let x = Nx.create Nx.float32 [| 3 |] [| 1.; 2.; 3. |] in
  let ct = Nx.ones Nx.float32 [| 3 |] in

  (* One-shot: *)
  let y, g = Rune.vjp' f x ct in

  (* Reusable pullback, as in JAX: *)
  let y', pullback = Rune.vjp_fun' f x in
  let g' = pullback ct in
  ignore (y, g, y', g')

Forward mode is nearly identical in both:

y, tangent = jax.jvp(f, (x,), (v,))
let () =
  let f v = Nx.mul v v in
  let x = Nx.create Nx.float32 [| 3 |] [| 1.; 2.; 3. |] in
  let v = Nx.ones Nx.float32 [| 3 |] in
  let y, tangent = Rune.jvp' f x v in
  ignore (y, tangent)

For functions returning a structure rather than one tensor, use vjp2/jvp2 with a second module describing the output.


5. vmap and Per-Sample Gradients

f_batched = jax.vmap(f)                      # map axis 0 of every input
jax.vmap(f, in_axes=(0, None))               # hold the second input fixed
per_sample = jax.vmap(jax.grad(loss))(batch) # per-example gradients

in_axes translates directly: Some i for a mapped axis, None for a constant, one entry per leaf in traversal order:

let () =
  let f v = Nx.sum (Nx.mul v v) in
  let batch = Nx.ones Nx.float32 [| 10; 5 |] in
  let results = Rune.vmap' f batch in
  ignore results (* shape [10] *)
(* Hold the second leaf fixed: *)
let ys =
  Rune.vmap ~in_axes:[ Some 0; None ] (module Pair) f pairs

(* Per-sample gradients: vmap2 of grad. *)
let per_sample =
  Rune.vmap2
    (module Example)
    (module Params)
    (fun ex -> Rune.grad (module Params) (loss ex) params)
    batch

Two honest caveats relative to jax.vmap:

  • rune's vmap has no batching rule for the fft family and matrix decompositions (cholesky, qr, svd, eig, eigh); those raise on batched inputs.
  • Implicit RNG inside the mapped function draws identical values for every lane (JAX avoids this by making you thread keys; in rune, thread randomness in as mapped inputs).

6. Custom Rules

The correspondence is direct. JAX:

@jax.custom_vjp
def f(x): return jnp.square(x)

def f_fwd(x): return jnp.square(x), x
def f_bwd(res, ct): return (ct * 2 * res,)
f.defvjp(f_fwd, f_bwd)

rune packs the same three pieces into one call — the forward function returns the residual alongside its result:

module Vec = struct
  type t = Nx.float32_t

  let map (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t) v = f v
  let map2 (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t -> ('a, 'b) Nx.t) = f
  let iter (f : 'a 'b. ('a, 'b) Nx.t -> unit) v = f v
end

let f x =
  Rune.custom_vjp
    (module Vec)
    ~fwd:(fun x -> (Nx.square x, x))
    ~bwd:(fun res ct -> Nx.mul ct (Nx.mul_s res 2.0))
    x

let () =
  let x = Nx.create Nx.float32 [| 3 |] [| 1.; 2.; 3. |] in
  Nx.print_data (Rune.grad' (fun v -> Nx.sum (f v)) x)

custom_jvp mirrors jax.custom_jvp the same way. One difference to know: in rune a custom_vjp raises if differentiated in forward mode (and vice versa) — define both rules if you need both modes, where JAX can sometimes transpose a JVP rule automatically.


7. Gradient Checkpointing

jax.checkpoint (a.k.a. jax.remat) maps to remat:

y = jax.checkpoint(expensive)(x)
let () =
  let expensive v = Nx.mean (Nx.square (Nx.sin v)) in
  let x = Nx.create Nx.float32 [| 3 |] [| 1.; 2.; 3. |] in
  let g =
    Rune.grad' (fun v -> Rune.remat (module Vec) expensive v) x
  in
  ignore g

Both recompute the wrapped function during the backward pass instead of retaining its intermediates; gradients are unchanged.


8. Control Flow

In JAX, Python control flow breaks under jit, so lax.cond/lax.scan/lax.while_loop are mandatory inside compiled functions. In rune everything runs eagerly, so ordinary OCaml control flow works inside every transformation:

let () =
  let f x = if Nx.item [] x > 0.0 then x else Nx.neg x in
  ignore (Rune.grad' f (Nx.scalar Nx.float32 2.0))

Rune still provides scan, cond, and while_loop — not because you need them today, but because their signatures are staging-ready: code written with them differentiates and vectorizes now, and a future jit can stage them as structured control flow instead of unrolled traces. lax.scan's carry-and-stacked-outputs contract translates directly:

final, ys = jax.lax.scan(f, init, xs)
let final, ys = Rune.scan (module Carry) ~f ~init xs

9. Jacobians, Hessians, HVPs

JAX rune
jax.jacfwd(f)(x) jacfwd' f x
jax.jacrev(f)(x) jacrev' f x
jax.hessian(f)(x) hessian' f x
jvp-of-grad HVP recipe hvp (module P) f params v / hvp' f x v

JAX's docs derive the Hessian-vector product as jvp of grad; rune ships that composition as hvp, matrix-free, for any parameter structure.


10. Gradient Checking

from jax.test_util import check_grads
check_grads(f, (x,), order=1)
let () =
  let f v = Nx.sum (Nx.mul v v) in
  let module V64 = struct
    type t = Nx.float64_t

    let map (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t) v = f v
    let map2 (f : 'a 'b. ('a, 'b) Nx.t -> ('a, 'b) Nx.t -> ('a, 'b) Nx.t) = f
    let iter (f : 'a 'b. ('a, 'b) Nx.t -> unit) v = f v
  end in
  match
    Rune.check_grads
      (module V64)
      f
      (Nx.create Nx.float64 [| 3 |] [| 1.; 2.; 3. |])
  with
  | Ok () -> print_endline "ok"
  | Error msg -> print_endline msg

Both compare autodiff against finite differences along directions rather than element by element. Use float64 for reliable results.


11. Randomness

JAX threads explicit splittable keys. Rune uses Nx's implicit scoped RNG: wrap the program in Nx.Rng.run ~seed for reproducibility, and Nx.rand/Nx.randn draw from the ambient scope:

let () =
  Nx.Rng.run ~seed:0 @@ fun () ->
  ignore (Nx.randn Nx.float32 [| 3 |])

The trade-off surfaces under vmap: with explicit keys you would pass one key per lane; with the implicit scope, in-function draws are identical across lanes, so per-lane randomness must be a mapped input.


12. What Rune Does Not Have (Yet)

JAX feature Status in rune
jax.jit Not implemented. Everything runs eagerly; scan/cond/while_loop are designed so a future jit can stage them.
GPU/TPU, jax.device_put Not implemented. CPU only.
jax.pmap / distributed Not implemented.
Full op coverage under AD Reverse mode raises on svd, eig, eigh, rfft, irfft, psum, mod; forward mode additionally on qr. detach inputs where gradients should not flow.
Full op coverage under vmap The fft family and decompositions raise on batched inputs.
jax.random keys Implicit scoped RNG instead; see §11.
Donation, sharding, pjit Not applicable without a compiler.

Rune's failure model is deliberate: operations without a rule raise Invalid_argument rather than silently producing zero or wrong gradients.


13. Quick Cheat Sheet

Task JAX rune
Gradient jax.grad(f)(params) grad (module P) f params
Gradient (one tensor) jax.grad(f)(x) grad' f x
Value + gradient jax.value_and_grad(f)(params) value_and_grad (module P) f params
Auxiliary output value_and_grad(f, has_aux=True) value_and_grad_aux (module P) f params
Parameter container pytree registration Ptree.S record + 3 one-line traversals
Dynamic tree pytree Rune.Ptree.t
VJP jax.vjp(f, x) then call vjp_fun (module P) f params / vjp'
JVP jax.jvp(f, (x,), (v,)) jvp (module P) f params v / jvp'
Batch map jax.vmap(f)(batch) vmap (module P) f batch / vmap' f batch
Axis control in_axes=(0, None) ~in_axes:[ Some 0; None ]
Per-sample grads vmap(grad(f)) vmap2 of grad
Custom reverse rule @jax.custom_vjp custom_vjp (module P) ~fwd ~bwd
Custom forward rule @jax.custom_jvp custom_jvp (module P) ~f ~jvp
Rematerialization jax.checkpoint(f) remat (module P) f
Jacobian jacfwd / jacrev jacfwd' / jacrev'
Hessian jax.hessian(f)(x) hessian' f x
HVP jvp-of-grad recipe hvp / hvp'
Scan jax.lax.scan(f, init, xs) scan (module C) ~f ~init xs
Stop gradient jax.lax.stop_gradient(x) detach x
Block region from AD no_grad (fun () -> ...)
Gradient check check_grads(f, (x,), 1) check_grads (module P) f params
Debug tracing jax.debug.print with_debug (fun () -> ...)
JIT jax.jit(f) Not yet available