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.
The valuation is written against a scalar type, SDouble. Each arithmetic operation appends a node to an AadTape. Control flow is unrolled: a loop over 252 fixings emits 252 blocks of nodes.
Walking the tape once in reverse, applying Eq. 1 at every node, leaves each input slot .
| property | value |
|---|---|
| record | one pass, plain Java |
| tape form | flat primitive-op list |
| forward replay | allocation-free array loop |
| reverse replay | one pass, all gradients |
| fusion | elementwise chain → 1 kernel |
| kernel cache key | generated source |
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.
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.
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.
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.
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.
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.
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.
Arithmetic Asian call, 252 fixings, price + five Greeks per scenario. Adjust the workload parameter.
| engine | fp | rel. throughput | t (1e10) |
|---|---|---|---|
| cpu — scalar oracle | 64 | — | |
| cpu-jit — bytecode | 64 | — | |
| simd — Vector API | 64 | — | |
| rocm — HIP | 32 | — | |
| nablatensor — vulkan | 32 | — |
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.
price=5.301676 delta=0.561932 vega=22.389375 rho=23.603735.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.
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.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).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.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.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);
}import com.nablatensor.quant.*;
import com.nablatensor.engine.SDouble;
import com.nablatensor.engine.Nabla;
// Product<M> is (recorder, inputs, timeGrid) -> record the discounted payoff, once.
Product<EquityMarket> cappedCall = (rec, in, grid) -> {
SDouble r = in.of(EquityMarket::rate), vol = in.of(EquityMarket::vol);
var sim = new GbmPath(rec, r, vol, grid, in.of(EquityMarket::maturity));
SDouble s = in.of(EquityMarket::spot);
for (int t = 0; t < grid.steps(); t++) s = sim.step(s, rec.randn(), t);
SDouble payoff = s.sub(in.of(EquityMarket::strike)).max(0.0).min(20.0); // capped call
rec.output(payoff.mul(r.neg().mul(in.of(EquityMarket::maturity)).exp())); // discounted
};
try (MonteCarlo<EquityMarket> mc = MonteCarlo.of(cappedCall)
.market(EquityMarket.atmOneYear()).steps(252)
.greeks().on("vulkan").build()) {
Nabla.TypedValuation<EquityMarket> p = mc.run(5_000_000, 42L);
EquityMarket g = p.greeks(); // g.spot() is delta, g.vol() is vega
}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.