nablatensor
Datasheet — adjoint automatic differentiation engine (JVM)

The value forwards. Every gradient back.

Abstract. NablaTensor records a Monte-Carlo valuation once, as a flat trace of primitive operations, and replays it across millions of scenarios — on generated bytecode, on SIMD, or on a GPU. One forward evaluation yields the price; one reverse accumulation yields the full first-order gradient, at a cost of approximately 1.3× the price evaluation and independent of the number of risk factors.

tape size (Asian, 252)≈1,500 nodes
reverse / forward cost≈1.3×
default backendcpu-jit (LTS)
determinismpath-for-path
1 — Overview

Two mechanisms over one recorded trace

The valuation is written against a scalar type, SDouble. Each arithmetic operation appends a node (op,operands)(\mathrm{op}, \text{operands}) to an AadTape. Control flow is unrolled: a loop over 252 fixings emits 252 blocks of nodes.

Eq. 1 — reverse accumulation (chain rule)
xˉi+=fkxiyˉk,yˉout=1\bar{x}_i \mathrel{+}= \frac{\partial f_k}{\partial x_i}\,\bar{y}_k,\qquad \bar{y}_{\text{out}} = 1

Walking the tape once in reverse, applying Eq. 1 at every node, leaves each input slot xˉi=V/xi\bar{x}_i = \partial V/\partial x_i.

propertyvalue
recordone pass, plain Java
tape formflat primitive-op list
forward replayallocation-free array loop
reverse replayone pass, all gradients
fusionelementwise chain → 1 kernel
kernel cache keygenerated source
2 — Method

Record once, replay many

Run the pricing code one time in record mode. The node list is the executable now. Replaying it forward gives the price; replaying it backward drops every sensitivity into its input row. Preparing the trace is the one-time cost; thereafter, moving the market is setInput plus replay — no re-record, no recompile.

Eq. 2 — central bump-and-revalue, for comparison
VxV(x+h)V(xh)2h1+2N evaluations\frac{\partial V}{\partial x}\approx\frac{V(x+h)-V(x-h)}{2h}\quad\Rightarrow\quad 1+2N\ \text{evaluations}
inputsspot strike vol rate + draws
forward252 fixings → avg → payoff → PV
seedȳ_out ← 1
reverseapply Eq. 1 at every node, once
outdelta vega rho dV/dK dV/dT
3 — Techniques

Six techniques, each standard

  1. The computation becomes data

    Recording the valuation once flattens it to a list of primitive operations — each row an opcode and the indices of its operands, with every branch and loop already unrolled away. Replaying that list for one scenario is a tight pass over primitive arrays: no virtual dispatch, no allocation, nothing that depends on the data. Parsing the model, folding constants and laying out storage is done once, at record time; from then on every path pays only for the arithmetic.

  2. Every derivative from one pass back

    Seed the output’s adjoint to one and walk the same list in reverse, applying each operation’s local derivative and accumulating it into its operands’ slots. By the time the pass reaches the inputs, every slot holds an exact partial derivative — four risk factors or four hundred, from a single sweep that costs about 1.3× a price-only pass. Bump-and-revalue instead reprices the whole model once per factor, so its cost climbs with every Greek you add.

  3. One launch, not one per op

    Executed op by op, a chain like mul → add → exp → max would write and re-read a full scenario-wide array at every step. The JIT fuses the chain into one generated kernel — bytecode for cpu-jit, a compute shader for the GPU backends — so each value is produced and consumed in registers and the batch is traversed once. The kernel is keyed by its generated source and cached, so re-feeding the market re-runs it with no recompilation.

  4. Randomness with no memory

    A conventional generator carries state that has to be advanced in sequence, which forces threads to share a lock or carve up the stream by hand. Philox is counter-based: draw i is a pure function of (i, seed), computed directly. A million scenarios can be handed to any number of CPU cores or GPU lanes with no coordination at all, and the same seed reproduces the same draws bit-for-bit across machines, backends and core counts.

  5. Never roll the same dice twice

    A risk run prices one base market, then hundreds of shocked variants of it. The draws depend on index and seed only — never on the market — so every one of those revaluations wants the identical block of random numbers. Computing it once and holding it removes what is, on CPU, the larger part of a replay’s cost, roughly doubling the run’s throughput; it also cancels sampling noise between the base and shocked prices, so the differences that become Greeks come out cleaner.

  6. Pick the engine, keep the code

    One recorded tape has five code generators behind it: a scalar tree-walk (cpu, the reference), straight-line bytecode (cpu-jit), the Vector API (simd), and fused GPU kernels for Vulkan, CUDA and ROCm. You pick one with a single .on("…"), or let .fastest() choose by what the box can actually load. The valuation — written once against SDouble — does not change a line, and every backend is checked against the scalar oracle.

4 — Measurements

Same tape, every backend the unit can run

Arithmetic Asian call, 252 fixings, price + five Greeks per scenario. Adjust the workload parameter.

enginefprel. throughputt (1e10)
cpu — scalar oracle64
cpu-jit — bytecode64
simd — Vector API64
rocm — HIP32
nablatensor — vulkan32

Fig. 2 — Single Ryzen 7 8845HS + Radeon 780M laptop, ~1,500-node adjoint tape, projected from a 1e6 probe. Illustrative of the mechanism; not a specification for other models or hardware.

Adjoint vs central bump — 2,000,000 scenarios, cpu-jit, JDK 25.0.1, Linux amd64, 16 vCPU: value + 5 Greeks, 1 reverse sweep, 1.11 s; central bump, 11 evaluations, 10.76 s; ratio 9.7. Agreement: price=5.301676 delta=0.561932 vega=22.389375 rho=23.603735.
5 — Deployment

Runs on the LTS Java you already deploy

One model, chosen at run time. The cpu oracle and the cpu-jit bytecode engine are plain JVM code with no native dependency; the GPU backends are thin java.lang.foreign bindings to the vendor driver — no JNI, no shim library to build. Every backend module ships regardless of what the box has installed and gates itself through AadEngine.isAvailable(), so a missing device or loader just makes selection fall back and mvn test stays green on a bare laptop.

  • JDK 25 (LTS) minimum. The hard floor is JDK 24, where the Class-File API (java.lang.classfile, part of java.base) became non-preview — cpu-jit uses it to emit the tape as a straight-line bytecode kernel. JDK 25 is the first LTS at that level. A plain runtime runs it: no javac, no preview or incubator flags on this path; a JDK is only needed to build NablaTensor itself.
  • simd is the one flagged path. The JDK Vector API it uses has been an incubator module since JDK 16 (March 2021) and still is in JDK 25 — held back deliberately for Project Valhalla, not because it is unfinished. The simd backend inherits that label: opt in with --add-modules jdk.incubator.vector. The API has been stable for years, so this runtime is fine for development and proofs of concept; only the default cpu-jit path needs no flag at all.
  • No Python in the loop. A JPype bridge exists purely for Jupyter convenience — exploring the engine from a notebook; nothing in the pricing path depends on it.
  • Nothing to pre-compile. Ordinary Java jars added as dependencies — no C compiler or nvcc in your pipeline, no JNI glue to ship. Every kernel is built in-process at run time: bytecode via the Class-File API, GPU code via the driver’s own runtime compiler (NVRTC / HIPRTC / shaderc).
  • Container-native. A stateless JVM library — no sidecars, no disk or network of its own — so it drops into any OCI image (Docker, Podman, containerd) and scales out as a plain Deployment on Kubernetes, OpenShift, ECS or Nomad; because the RNG is counter-based, replicas share nothing. A jlink’d java.base runtime keeps the cpu-jit image small, and that path needs neither an on-image compiler nor a writable exec tmpdir, so a read-only root filesystem and a tight securityContext just work. GPU pods add the usual device plugin (NVIDIA / AMD ROCm) and --enable-native-access.
  • Deterministic to the path. A counter-based RNG hands every backend the same set of simulated paths for a given seed — runs price the exact same scenarios, not just statistically similar ones. A GPU result is then checked against the plain-Java cpu oracle by direct subtraction, with no sampling noise to explain away: fp64 backends match to the bit, fp32 GPU to ~5 decimals on price and delta.
  • .fastest() = CUDA > ROCm > Vulkan > SIMD > CPU.
6 — Interface

Record the market; request the gradient

Build a MonteCarlo once, select a backend, and run().

import com.nablatensor.quant.*;
import com.nablatensor.engine.Nabla;

EquityMarket market = EquityMarket.atmOneYear();          // S0=K=100, sigma=20%, r=3%, T=1y

try (MonteCarlo<EquityMarket> mc = MonteCarlo.of(Products.asianCall())  // seam 1: swap for any payoff
        .market(market)
        .steps(252)                                       // or .timeGrid(TimeGrid.of(t1, t2, ...))
        .greeks()                                         // value + every first-order Greek
        .on("cpu-jit")                                    // or .fastest(), or "simd" / "vulkan"
        .build()) {

    Nabla.TypedValuation<EquityMarket> p = mc.run(1_000_000, /*seed*/ 42L);
    EquityMarket g = p.greeks();                          // gradient, shaped like the market

    System.out.printf("price %.4f  delta %.4f  vega %.4f  rho %.4f  (stderr %.4f)%n",
        p.price(), g.spot(), g.vol(), g.rate(), p.standardError());

    var bumped = mc.run(market.withSpot(101.0), 1_000_000, 42L);
}
7 — Verification

Scalar oracle and evidence pack

Validation. Every backend replays the same tape; the scalar cpu engine is the reference and shares the Philox stream path-for-path, so an accelerated result is checked with no statistical allowance. nablatensor-validate emits a text evidence pack.

Cross-check. The same harness reprices by central bump-and-revalue on the oracle, with common random numbers, and diffs that against the adjoint gradient — the residual is Monte-Carlo noise, not a coding error. NablaTensor is a computation engine, not a framework. The pieces deep learning needs are here — reverse-mode autodiff, tensor ops, GPU kernels — but the tensor layer is kept deliberately small, in service of the pricing tape. Nor is it a market-data platform.

Write the sum down once. Read all the risk off it in one pass back. Never roll the same dice twice.

Read the documentation →