← blog35 min read

A JIT inside a JIT: how cpu-jit compiles adjoint Greeks into bytecode

A Monte-Carlo payoff recorded once as a 26-node tape, turned into JVM bytecode at run time with the Class-File API, then compiled again by HotSpot. What the generated code looks like instruction by instruction, why one constant nobody set on purpose costs 5.8x, and where the time in a path actually goes.

autodiffjvmbytecodejitgreeksbenchmarkscompilers
A pipeline from 'your payoff — plain Java, ADouble' through 'AadTape — 26 primitive nodes' and 'generated class — 1,243 bytes, hidden' to 'HotSpot C2' and 'machine code'. Below, two panels. Left: CPU replay at 8 threads, fp64 — price only cpu 68.7, cpu-jit 153.2, simd 406.7 Mpath/s; value + 5 Greeks cpu 41.5, cpu-jit 92.5, simd 207.8. Right, headed 'THE 8,000-BYTE CLIFF': a 1,536-node Asian tape at segment sizes 128, 256, 512, 600 and none, with reverse-method sizes 3,701 / 7,383 / 14,763 / 17,294 / 36,315 bytes and throughput 1.418, 1.454, 1.397, 0.436, 0.305 for the price and 1.246, 1.256, 0.217, 0.124, 0.110 for value plus five Greeks, with a dashed line marked 'HotSpot stops compiling here' between 256 and 512. A banner reads 'Generating code is the easy half. Generating code the other compiler will accept is the whole job.' A footer strip breaks a price path into Philox + Box-Muller 38%, exp 26%, tape 36%.

Here is the experiment that made this article worth writing.

Take one recorded Monte-Carlo tape. Compile it to bytecode. Run it. Now change a single number in the code generator — not the maths, not the payoff, not the JVM flags, not the thread count. Just how many tape nodes the generator puts into each emitted method. From 256 to 512.

The price pass barely moves: 1.454 million paths per second before, 1.397 after. The Greeks fall from 1.256 to 0.217. A 5.8× collapse, from a constant that nobody chose deliberately and that does not appear anywhere in the arithmetic.

That is the whole subject of this article in one result. Generating machine code from a tape is the easy half. Generating code that the other compiler — the one already inside your JVM — is willing to look at is the entire job, and the failure mode is not a warning or an exception. It is silence, and a number six times smaller than it should be.

So: what a recorded tape actually is, what cpu-jit turns it into, what those instructions look like one at a time, where the 8,000-byte cliff comes from, why the same class file is 234 KB or 1.6 KB depending on a flag, and — the part that reframes everything else — what fraction of a Monte-Carlo path the compiled tape is responsible for at all.

Every throughput number below was re-measured on referential machine (Ryzen 7 8845HS, 8 cores, Zulu OpenJDK 25.0.1) for this update, except the single-threaded cost breakdown in Part eight, which is carried over from the original pass on the same machine and noted where it appears.

Two bands. Upper band, 'once per model — about 24 ms, total': your payoff (plain Java over ADouble) → AadRecorder (runs it exactly once, 3.4 ms) → AadTape (26 primitive nodes, no Java left) → KernelGenerator (java.lang.classfile, 20 ms) → hidden class (1,243 bytes, forward() reverse()), with a dashed arrow noting the class is handed to HotSpot like any other and C2 compiles it on the usual invocation counters. Lower band, 'once per path — a hundred million times, across eight threads': fill draws[] (Philox(path, seed)) → forward(v, in, draws) → reverse(v, d) → accumulate, looping back. A footer notes the generated class is hidden — no name in any class loader, collectable when the pricer is closed — the same mechanism the JVM uses for lambdas.

The whole pipeline. Everything above the dashed line happens once; everything below it happens a hundred million times. The interesting engineering is entirely about making the second band cheap by spending 24 ms in the first.

Part one: what a tape is

Adjoint algorithmic differentiation rests on a trick that sounds like cheating the first time you meet it and then never stops being useful. You want the derivative of a valuation with respect to a lot of inputs. Instead of differentiating the program, you record what the program did — the actual sequence of arithmetic operations it performed on this particular set of inputs — and then differentiate that.

The recording is called a tape. In NablaTensor it is produced by running your payoff exactly once against a scalar type that computes nothing:

public final class ADouble {
  public ADouble mul(ADouble other) {
    return binary(AadOp.MUL, other);      // appends a node; returns a handle
  }
  public ADouble exp() {
    return unary(AadOp.EXP);
  }
  // ...
}

Every arithmetic method on ADouble appends one entry to the recorder's tape and hands back a reference to that entry. No number is ever computed while recording. By the time your payoff has run once, the tape holds a flat, numbered list: for each node, an opcode, up to two argument indices, and possibly a constant.

That is the entire intermediate representation. Here is a real one — the complete tape for a one-step European call, printed straight out of the engine:

european steps=1 nodes=26 inputs=5 randTotal=1 output=25
  0  INPUT  v0   = in[0]  // spot                active
  1  INPUT  v1   = in[1]  // strike              active
  2  INPUT  v2   = in[2]  // vol                 active
  3  INPUT  v3   = in[3]  // rate                active
  4  INPUT  v4   = in[4]  // maturity            active
  5  MUL    v5   = v2 * v2                       active
  6  CONST  v6   = 0.5                           -
  7  MUL    v7   = v5 * v6                       active
  8  SUB    v8   = v3 - v7                       active
  9  CONST  v9   = 1.0                           -
 10  MUL    v10  = v4 * v9                       active
 11  MUL    v11  = v8 * v10                      active
 12  SQRT   v12  = sqrt(v10)                     active
 13  MUL    v13  = v2 * v12                      active
 14  RANDN  v14  = randn #0                      -
 15  MUL    v15  = v13 * v14                     active
 16  ADD    v16  = v11 + v15                     active
 17  EXP    v17  = exp(v16)                      active
 18  MUL    v18  = v0 * v17                      active
 19  SUB    v19  = v18 - v1                      active
 20  CONST  v20  = 0.0                           -
 21  MAX    v21  = max(v19, v20)                 active
 22  NEG    v22  = -v3                           active
 23  MUL    v23  = v22 * v4                      active
 24  EXP    v24  = exp(v23)                      active
 25  MUL    v25  = v21 * v24                     active

Read it and the whole Black-Scholes path falls out. Nodes 5–8 build r − σ²/2. Node 10 is T × 1.0 (the time grid's fraction of maturity for a one-step grid). Node 11 is the drift over the step; nodes 12–15 the diffusion σ√T·Z; node 17 the exponential; node 18 the terminal spot. Nodes 19–21 are max(S(T) − K, 0), and 22–25 discount it. Twenty-six operations, five of which are the inputs you want Greeks for.

There is no if, no loop, no function call, no object. That is not a limitation of the recorder — it is the point. A tape is a straight-line program over scalars, and a straight-line program over scalars is the easiest thing in the world to compile, to vectorise, to ship to a GPU, or to walk backwards.

A 26-row table. Each row shows the node index, the operation (INPUT, CONST, RANDN, MUL, SUB, ADD, EXP, SQRT, NEG, MAX), the forward expression it computes, and, in a right-hand column, the reverse rule the code generator emits. INPUT, CONST and RANDN rows are marked 'no adjoint to propagate'. MUL rows each emit two accumulations such as 'd0 += d18×v17 ; d17 += d18×v0'; SUB emits 'd3 += d8 ; d7 -= d8'; MAX emits a branch 'v19≥v20 ? d19+=d21 : d20+=d21'. A footer notes that node 25 is the output, that the reverse sweep starts with d25 = 1 and walks bottom to top, and that when it reaches rows 0 to 4 those hold delta, dV/dK, vega, rho and theta.

Left column: what the forward sweep computes. Right column: what the reverse sweep does with it. Notice how much denser the right column is — every MUL becomes two read-modify-write accumulations. That density ratio is going to matter a great deal in a few sections’ time.

The tape has a proper name, and it is fifty years older than this code. In the automatic-differentiation literature a flat list of primitive operations with argument indices is a Wengert list, after R. E. Wengert's 1964 paper "A simple automatic derivative evaluation program" in Communications of the ACM — two pages that quietly founded the field. Wengert did forward mode.

The reverse sweep — the one that gets you every Greek at once — was written down for the first time in Seppo Linnainmaa's 1970 master's thesis at the University of Helsinki, in Finnish, titled The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Read the title again. He was not computing derivatives to do anything with them. He wanted to know how floating-point error propagates through an algorithm, and reverse-mode accumulation fell out as the efficient way to answer that question. Backpropagation, adjoint Greeks and every gradient-descent step you have ever run descend from a numerical-analysis paper about rounding error.

Making it automatic took another decade: Bert Speelpenning's 1980 Illinois PhD thesis, Compiling fast partial derivatives of functions given by algorithms, was the first system to generate reverse-mode gradient code from a program without a human doing the differentiation.

The two schools, and the third thing

By the 1990s the AD community had settled into two camps, and the split is worth understanding because cpu-jit sits in neither.

Source transformation reads your Fortran or C at build time and writes new source that computes the derivative — ADIFOR, Tapenade, TAF. The output is ordinary code, compiled by your ordinary compiler, and it is fast, because the optimiser sees everything. It is also rigid: your program has to be the kind of thing the transformer can parse and reason about, and you re-run the tool every time you change a line.

Operator overloading — ADOL-C, CppAD, NAG's dco/c++, and NablaTensor's ADouble — replaces double with an active type that records as it goes. It handles anything the host language can express, including branches and library calls, because it only ever sees what actually executed. The cost is that somebody has to run the tape afterwards, and running a tape is interpretation.

cpu-jit is a third thing: overload to record, then transform to source at run time. The recording is as flexible as overloading, because it is overloading. The execution is as fast as source transformation, because by the time paths are running the tape has been turned into instructions. What makes it possible is that the JVM will happily accept a class you invented ten milliseconds ago.

How this got to a trading floor. Reverse-mode AD arrived in quantitative finance in January 2006, in a Risk magazine paper by Mike Giles and Paul Glasserman with one of the great titles in the field: "Smoking adjoints: fast Monte Carlo Greeks". They applied it to the LIBOR market model and reported deltas for the whole forward curve at a small multiple of one pricing run, against a bump ladder whose cost grew with every rate.

The industry noticed hard. Risk's readers voted it the best paper of 2006 and the magazine named Giles and Glasserman joint Quants of the Year. The detail that makes the story is where Giles came from: computational fluid dynamics. Adjoint methods had been standard in aerodynamic shape optimisation for two decades — you want the sensitivity of drag to a thousand points on a wing surface, and nobody was ever going to get that by re-solving Navier-Stokes a thousand times. Giles moved fields, recognised the same shape of problem, and carried the technique across. A Greek is a wing.

For a few years after that, "we have adjoint AD in production" was something banks said in recruiting pitches and did not say in papers.

Part two: why not just interpret it?

You have a tape. The obvious thing to do with it is walk it. NablaTensor's cpu engine does exactly that, and its inner loop is about as tight as a tape interpreter gets:

for (int i = 0; i < n; i++) {
  int a = argA[i];
  int b = argB[i];
  v[i] = switch (ops[i]) {
    case CONST -> constants[i];
    case INPUT -> in[a];
    case RANDN -> rng[b].normal();
    case ADD   -> v[a] + v[b];
    case MUL   -> v[a] * v[b];
    case EXP   -> Math.exp(v[a]);
    // ...nine more
  };
}

The arrays are flattened once in the constructor, so there is no virtual call per node and no pointer chasing. This is a good interpreter. And it is still doing, for every one of the 26 nodes, on every one of a hundred million paths: three array loads with bounds checks (argA[i], argB[i], ops[i]), a tableswitch on the opcode, and an indirect branch that the hardware branch predictor has no hope of getting right, because the op sequence is MUL, CONST, MUL, SUB, CONST, MUL, MUL, SQRT, … and predictors are trained on repetition, not on a specific 26-long word.

The multiply itself is one instruction. Everything around it is bookkeeping to answer a question — which operation is this? — that was already settled before the first path ran.

Two side-by-side panels for the same tape node, v18 = v0 × v17. Left, 'cpu — TAPE INTERPRETER': the Java switch loop reading argA[i], argB[i] and ops[i], annotated 'per node: 3 array loads · 1 indirect branch · bounds checks'. Right, 'cpu-jit — GENERATED BYTECODE': ten instructions from aload_0 through dmul and dastore, with the indices 18, 0 and 17 highlighted as 'baked in', annotated 'per node: 2 array loads · 1 multiply · nothing to decide'. Below, bar charts at 8 threads: price only, cpu 68.7 versus cpu-jit 153.2 Mpath/s; value plus five Greeks, cpu 41.5 versus cpu-jit 92.5. A banner reads that this is 2.2× on both the price pass and the adjoint pass, and that both engines return the identical double bit for bit.

The generated kernel does not do the arithmetic differently. It has simply stopped asking which arithmetic to do.

Measured on referential machine, eight threads, fp64, the one-step European:

enginethreadsprice onlyvalue + 5 Greeksadjoint cost
cpu (tape interpreter)111.5 Mpath/s6.8 Mpath/s1.69×
cpu-jit (generated bytecode)122.814.71.55×
cpu868.741.51.66×
cpu-jit8153.292.51.66×
simd (JDK Vector API)8406.7207.81.96×

Two things to read off. The generated kernel is a straight 2.2× over a good interpreter, on both price and Greeks — that is the cost of dispatch, and nothing else, because the two engines execute the identical sequence of floating-point operations. And the adjoint sweep costs about 1.66× a price pass, not the 11× a five-Greek central-bump ladder would cost. That is the whole argument for adjoint AD, and it is orthogonal to everything in this article: it holds on the interpreter too.

Part three: emitting the code

Now the interesting part. How do you turn that tape into a class?

Until recently the answer on the JVM was "use ASM", the bytecode library that has quietly underpinned Spring, Hibernate, Kotlin, Mockito and roughly everything else since 2002. Since JDK 24 there is a standard answer in the platform: java.lang.classfile, the Class-File API.

Why the JDK grew its own bytecode library. The JDK already contained ASM — it needs to generate classes at run time for every lambda you write, and to read them in jar and jlink. Bundling a third-party library for that created a genuinely funny bootstrapping problem, which JEP 484 states with admirable bluntness:

The ASM version for JDK N cannot finalize until after JDK N finalizes, so tools in JDK N cannot handle class-file features that are new in JDK N, which means javac cannot safely emit class-file features which are new in JDK N until JDK N+1.

Read that twice. Java's own compiler could not use a new class-file feature in the release that introduced it, because the library the JDK used to read class files did not yet know the feature existed. Under the old three-year release cadence this was an inconvenience. Under the six-month cadence it was a permanent one-release tax on the entire platform.

So the Class-File API was written: previewed as JEP 457 in JDK 22, refined as JEP 466 in JDK 23, final as JEP 484 in JDK 24. It is the reason cpu-jit has no dependencies. A nablatensor install that generates optimised kernels at run time pulls in exactly zero jars to do it.

The generator is a walk over the tape with one case per opcode. This is the entire forward emitter, with only two debug probes elided:

private static void fwdNode(CodeBuilder cb, Tape tp, int i, Slots s, T t) {
  int a = tp.a[i], b = tp.b[i];
  switch (tp.op[i]) {
    case CONST -> s.storeV(cb, i, () -> t.load(cb, tp.k[i]));
    case INPUT -> s.storeV(cb, i, () -> s.loadInput(cb, a));
    case RANDN -> s.storeV(cb, i, () -> s.loadDraw(cb, i));
    case ADD   -> s.storeV(cb, i, () -> { s.loadV(cb, a); s.loadV(cb, b); t.add(cb); });
    case MUL   -> s.storeV(cb, i, () -> { s.loadV(cb, a); s.loadV(cb, b); t.mul(cb); });
    case EXP   -> s.storeV(cb, i, () -> { s.loadV(cb, a); t.math1(cb, "exp"); });
    case MAX   -> s.storeV(cb, i, () -> { s.loadV(cb, a); s.loadV(cb, b);
                                          cb.invokestatic(CD_MATH, "max", t.mathBin()); });
    // ...
  }
}

storeV and loadV are an interface, not a fixed emission — that indirection is what lets the same fifteen formulas serve the flat kernel (values in an array) and the rolled kernel (values in JVM locals) without a second copy of the derivative rules. It matters later.

Here is what comes out. This is javap -c on the class the engine actually generated for the tape above, at node 18 — v18 = v0 × v17:

168: aload_0                     // the v[] array
169: bipush        18            // the destination slot — a literal
171: aload_0
172: iconst_0                    // node 0's slot — a literal
173: daload                      // v[0], the spot
174: aload_0
175: bipush        17            // node 17's slot — a literal
177: daload                      // v[17], exp(drift + vol*Z)
178: dmul
179: dastore                     // v[18] = the product

Ten instructions, and every index that the interpreter would have had to fetch out of a parallel array is a constant sitting in the instruction stream. The JVM has no registers at the bytecode level, so values move through an operand stack: push, push, consume-two-push-one.

Ten small stack diagrams in a row, one per instruction, tracing the JVM operand stack for v18 = v0 × v17. aload_0 pushes the v[] array reference; bipush 18 pushes the constant 18; aload_0 and iconst_0 push the array and index 0; daload consumes them and leaves 100.0; aload_0 and bipush 17 push the array and 17; daload consumes them and leaves 1.0942; dmul consumes both doubles and leaves 109.42; dastore consumes the array, the index and the value and leaves the stack empty. A legend distinguishes array references, constant indices baked in at generation time, and doubles the path actually computed.

The same ten instructions, drawn as what the operand stack holds after each one. The teal boxes are the whole story: in the interpreter every one of those was a load from a side array plus a bounds check.

Where the class goes

The bytes come back as a byte[], and then:

Class<?> cls = MethodHandles.lookup().defineHiddenClass(bytes, true).lookupClass();
return cls.getConstructor().newInstance();

A hidden classJEP 371, JDK 15. It has no binary name that any class loader will resolve, so nothing can link against it, reflect it by name, or accidentally hold it alive. It is unloadable independently of its loader, so closing a pricer really does let the kernel go. And it is not an exotic corner of the JVM: it is the mechanism invokedynamic uses for lambdas, which means your JVM has been generating and loading hidden classes since long before you asked it to generate one on purpose. Hidden classes replaced sun.misc.Unsafe.defineAnonymousClass, which did the same job for years while being, by name and by nature, something nobody was supposed to be using.

The generated class implements a two-method interface:

public interface JitKernel {
  double forward(double[] v, double[] in, double[] draws, double[] scratch);
  void   reverse(double[] v, double[] d, double[] scratch, double[] draws);
}

Which means the replay loop — the part that runs a hundred million times — is two megamorphic-free virtual calls into straight-line code:

kernel64.forward(v, in, draws, scratch);
acc.value += v[outputNode];
if (adjoints) {
  Arrays.fill(d, 0.0);
  d[outputNode] = 1.0;
  kernel64.reverse(v, d, scratch, draws);
  for (int j = 0; j < grad.length; j++) grad[j] += d[inputNode[j]];
}

d[outputNode] = 1.0 is the entire seeding ritual of reverse-mode AD: the derivative of the output with respect to itself. Everything else is the tape propagating it.

Part four: the reverse sweep is code too

The forward sweep is a transcription. The reverse sweep is where the code generator earns its name, because there is no source program to transcribe — the adjoint of a tape is something you derive, one local rule per opcode:

case MUL -> {
  s.addD(cb, a, () -> { s.loadD(cb, i); s.loadV(cb, b); t.mul(cb); });   // d[a] += d[i] * v[b]
  s.addD(cb, b, () -> { s.loadD(cb, i); s.loadV(cb, a); t.mul(cb); });   // d[b] += d[i] * v[a]
}
case EXP  -> s.addD(cb, a, () -> { s.loadD(cb, i); s.loadV(cb, i); t.mul(cb); });
case LOG  -> s.addD(cb, a, () -> { s.loadD(cb, i); s.loadV(cb, a); t.div(cb); });
case SQRT -> s.addD(cb, a, () -> { s.loadD(cb, i); t.load(cb, 0.5); t.mul(cb);
                                   s.loadV(cb, i); t.div(cb); });

Three of these are worth pausing on.

EXP reads v[i] — its own forward output — rather than recomputing exp(v[a]). Because d/dx e^x = e^x, and the forward sweep already left the answer in v[i]. The same trick works for SQRT. This is why keeping the forward values around is not a memory tax you tolerate; it is the thing that makes the reverse sweep cheap.

MAX has no derivative at the kink, so the generator emits a branch:

 96: dcmpg                       // compare v[19] and v[20]
 98: iflt          115
101: ... d[19] += d[21]          // the option is in the money
112: goto          126
115: ... d[20] += d[21]          // it is not; the adjoint dies here

Which is exactly the right answer for a payoff: on paths that finish out of the money, the whole sensitivity chain terminates at node 21 and never reaches the inputs. The kink is measure-zero and the estimator is unbiased, and the branch is what makes it so.

And every rule is +=, never =, because a node may be read by several later nodes and its adjoint is the sum of the contributions. Node 2 (vol) is read at node 5 twice and again at node 13 — three contributions. In bytecode a read-modify-write on an array element wants the array reference and index on the stack twice, so the generator uses dup2:

148: aload_1                     // the d[] array
149: iconst_0                    // slot 0
150: dup2                        // duplicate both — one pair to read, one to write
151: daload                      // d[0]
... compute the contribution ...
161: dadd
162: dastore                     // d[0] = d[0] + contribution

Why += is the cheap-gradient theorem in disguise. Every node contributes to the adjoint of each of its arguments exactly once, so the reverse sweep touches each edge of the graph once, in the opposite direction to the forward sweep. Its cost is therefore a fixed small multiple of the forward cost — and, crucially, independent of how many inputs there are. This is the Baur–Strassen theorem (1983): the gradient of a rational function costs at most a small constant times the function itself, no matter how many variables it has.

That constant is the number you are looking at when this article says 1.66×. It is not an implementation detail; it is a theorem about arithmetic circuits, and it says that a hundred Greeks cost the same as five.

The consequence for code size is what sets up the next section. Look back at the tape figure: INPUT, CONST and RANDN rows emit nothing in reverse, but every MUL emits two full read-modify-write accumulations. Measured on the real generated classes, the reverse sweep for a given block of nodes is about 2.5× the bytecode of the forward sweep for the same block.

Part five: the 8,000-byte cliff

Here is the trap.

A straight-line transcription of an n-node tape produces roughly 11 bytes of forward bytecode and 29 bytes of reverse bytecode per node. For the 26-node European that is a 1,243-byte class and nobody cares. For a 252-step Asian — 1,536 nodes — the forward sweep alone would be about 20 KB of bytecode in a single method.

HotSpot will not compile that method. Ever.

The relevant flag is -XX:+DontCompileHugeMethods, on by default, and its threshold HugeMethodLimit, 8,000 bytes of bytecode. (It is a HotSpot develop flag, so it does not show up in -XX:+PrintFlagsFinal on a production build — but the measurements below bracket it between 6,836 and 8,067, which is about as close as you can get to reading a constant out of a black box.) A method over that size is skipped by the JIT compiler entirely and executes in the bytecode interpreter for the life of the process. There is no warning. Nothing throws. Your carefully generated kernel simply runs at interpreter speed, which is worse than the tape interpreter you were trying to beat, because at least that one gets compiled.

So KernelGenerator splits both sweeps into fwd$0, fwd$1, … and rev$0, rev$1, … of at most segNodes nodes each — 128 by default — and the public forward/reverse methods are nothing but a list of invokestatic calls. Values that cross a segment boundary live in the shared v[]/d[] arrays, which they were living in anyway.

To find out what that is worth, I varied the segment size on the 1,536-node Asian and measured both the emitted method sizes and the throughput:

-Dnablatensor.jit.segfwd$k bytesrev$k bytespricevalue + 5 Greeks
128 (default)1,4633,7011.418 Mpath/s1.246 Mpath/s
2563,2497,3831.4541.256
5126,83614,7631.3970.217
6008,06717,2940.4360.124
(off — one method each)21,28636,3150.3050.110

The seg = 512 row is the one to stare at. The forward method is 6,836 bytes — under the limit, compiled, fine — and the price pass is essentially unaffected: 1.397 against the default's 1.418. The reverse method is 14,763 bytes, over the limit, never compiled, and the adjoint pass falls 5.8×. One tuning constant selectively destroyed the Greeks and left the price alone, because the forward/reverse bytecode ratio put the two sweeps on opposite sides of an 8,000-byte line.

Push to seg = 600 and the forward method crosses too, at 8,067 bytes, and now the price pass collapses as well.

A chart with five rows, one per segment size, showing forward and reverse method sizes as horizontal bars on a log scale against a vertical red line marked '8,000 — HugeMethodLimit, C2 never compiles anything to the right of this line'. At seg 128 and 256 both bars are left of the line and throughput is 1.418/1.246 and 1.454/1.256 Mpath/s. At seg 512 the forward bar is left of the line and the price stays at 1.397, but the reverse bar is right of it and the Greeks fall to 0.217. At seg 600 both bars cross and both figures collapse. With segmentation off both are far right and throughput is 0.305/0.110. A caption notes the reverse sweep emits about 2.5 times the bytecode per node, so it hits the ceiling first.

A performance cliff with no error message. The reverse sweep crosses the line first because its per-node bytecode is denser — which is a fact about the chain rule, showing up as a fact about HotSpot.

"Fine, I'll just turn the limit off." I tried. Running the one-method-per-sweep kernel with -XX:-DontCompileHugeMethods moves the price pass from 0.305 to 1.040 Mpath/s and the adjoint pass from 0.110 to 0.163 — better, and still 1.4× and 7.7× short of the segmented kernel.

Forcing C2 to compile a 36 KB method does not get you good code out of it. Live ranges span the entire method, register allocation degenerates, the compile itself takes a long time and the result spills constantly. The limit is not arbitrary bureaucracy; it is the JIT telling you, correctly, that it has nothing useful to offer at this size. The fix is to emit methods a compiler can actually reason about, which is the same advice a compiler engineer would have given a human writing the code by hand.

The general lesson is one every code generator learns eventually and this one learned the expensive way. When you generate code, you are not writing to a machine. You are writing to another compiler, and that compiler has opinions, budgets and cliffs. MaxInlineSize is 35 bytes. FreqInlineSize is 325. HugeMethodLimit is 8,000. None of those numbers are in your problem domain, and all of them are in your performance profile.

Part six: unrolled by default, rolled on request

The flat kernel is a full unroll. A 252-step Asian becomes 1,536 nodes becomes a 59 KB class; a 1,024-step tape becomes 6,168 nodes and a 234 KB class. That is a lot of instruction cache for a loop body you wrote once.

So there is a second code generator. JitOptimizations.Level.LOW_RISK turns on loop detection: scan the tape for a prologue · body×N · epilogue shape, confirm that every block is structurally identical up to a constant index shift, and emit the body once inside a real bytecode loop. Values that live across an iteration become JVM locals instead of array slots; the values the reverse sweep needs per iteration go into a compact step tape.

That last part is checkpointing, and it is the subtle bit. A rolled forward loop overwrites its locals every iteration, but the reverse sweep needs each iteration's values. So the generator works out exactly which body positions the adjoint rules read — MUL needs both arguments, EXP needs only its own output, ADD needs nothing — and tapes only those, iters deep. For the Asian that is two values per step out of a six-node body. The detector reports its findings honestly if you ask it:

[jit] rolled(period=6 x251, tape=2) nodes=1536 fp64 classBytes=1664

Six-node body, 251 iterations, two values taped per iteration. The class went from 59,176 bytes to 1,664.

Two panels comparing flat and rolled code generation for Asian tapes of 30, 48, 120, 408, 1536 and 6168 nodes. Panel A, emitted class file on a log scale: flat grows 1,339 → 1,943 → 4,281 → 15,428 → 59,176 → 239,469 bytes while rolled stays at 1,339, 1,943, 1,659, 1,659, 1,664, 1,664 (the first two rows are marked 'no loop found'). Panel B, time to generate on a log scale: flat stays between roughly 1.6 and 13 milliseconds once warmed up while rolled climbs into the hundreds of milliseconds, reaching about 830 at 1,024 steps. A banner notes the rolled kernel is 144 times smaller and about 60 times slower to produce at 1,024 steps, because the loop detector rescans the tape at every period from 3 to 40, and that it bought a few percent at run time, noisily.

The rolled generator wins the size axis by two orders of magnitude and loses the generation-time axis by nearly as much. Which one you care about depends entirely on how many times you are going to run the thing.

And now the honest part, which is not what the project's own internal notes said.

The rolled kernel did not reproduce its reputation. NablaTensor's internal performance document claimed the rolled-loop win was "large on the 252-step Asian". Re-measured for this update, five repeat runs on referential machine:

252-step Asian, 8 threadspricevalue + 5 Greeks
flat (default)1.368 Mpath/s1.220 Mpath/s
rolled (LOW_RISK)1.4051.269

Averaged, rolled comes out about 3% faster on price and 4% faster on the adjoint sweep — the Greeks figure lands close to this article's original ~5% finding. But individual runs ranged from -12% to +17% on that same comparison, so treat either percentage as "a few percent, not reliably in the same direction every run" rather than a precise number. It is nothing like "large" either way.

Meanwhile the generation cost is real and grows badly: the detector tries every period from 3 to 40 at every start offset, so producing the rolled kernel for a 6,168-node tape takes about 830 ms against the flat generator's roughly 13 ms once HotSpot has warmed up.

This is why the roller is opt-in and off by default, and it is a good argument for the project's convention that optimisations must be named explicitly rather than inferred. An optimisation that is a coin flip on the case you measured and costs 830 ms on the case you did not is exactly the kind of thing that should require someone to type its name.

Where it would pay: tapes far larger than instruction cache, or a process that builds thousands of pricers where 234 KB of class metadata each starts to add up. Neither is the 26-node European.

One footnote on those generation times, because it is the same joke one level up: the ~20 ms quoted earlier for building the European kernel is the first one in the process. The second model built in the same JVM takes about 1.6 ms, because by then HotSpot has compiled the code generator. The compiler that compiles your payoff needed compiling too.

Part seven: does it compute the same number?

A code generator that is fast and subtly different is worse than no code generator, because you will find out about the difference from a P&L attribution meeting.

So: same tape, same seed, same path count, every CPU replay strategy this engine has, printed to seventeen significant digits.

european steps=1 paths=2,000,000 seed=42
engine   thr  jit        price                  delta                  vega
cpu      1    -          9.4094807415033050     0.59872260307813430    38.644884800242730
cpu      8    -          9.4094807415028880     0.59872260307811660    38.644884800241820
cpu-jit  1    -          9.4094807415033050     0.59872260307813430    38.644884800242730
cpu-jit  8    -          9.4094807415028880     0.59872260307811660    38.644884800241820
cpu-jit  8    LOW_RISK   9.4094807415028880     0.59872260307811660    38.644884800241820
simd     8    -          9.4094807415028900     0.59872260307811790    38.644884800241720
analytic -    -          9.4133976292812000     0.59870630802372290    38.666811680284930

The interpreter and the generated kernel agree bit for bit — at one thread, at eight threads, and with the rolled generator on. Not "to seven figures"; identically, all 53 bits of mantissa.

That is not luck, it is a design constraint. The default code generator emits Math.exp, not a faster polynomial; it emits a*b in the recorded order rather than reassociating; it accumulates adjoints in tape order. Every one of those is a place where a couple of percent was available and was left on the table so that the answer would not move. If you want the percent, you name the optimisation and accept that the low bits change — FAST_MATH says so in its own Javadoc.

The 1-thread and 8-thread rows do differ, in the sixteenth digit. That is summation order across workers: eight partial sums added in a different sequence than one running total. It is inherent to parallel reduction, it is about 4×10⁻¹⁶ relative, and Monte-Carlo standard error on two million paths is around 10⁻²·⁵. The physics of the estimator dwarfs it by thirteen orders of magnitude.

On the 1,536-node Asian the picture is very slightly looser: at eight threads cpu, flat cpu-jit and rolled cpu-jit are still bit-identical (5.3106693425412870), while at one thread the generated kernels differ from the interpreter in the last digit (…413450 versus …413460). Longer tape, more places for C2 to contract a local += x*y into a fused multiply-add in one shape and not the other. One ULP, documented, on a 200,000-path estimate whose own standard error is in the third decimal.

Why the bit-exactness is worth the couple of percent. Not for the price — nobody quotes a Monte-Carlo price to sixteen digits. It is worth it because of regression testing. When the interpreter and the compiled kernel return the identical double, any difference between them is a bug, and you can assert on equality in CI instead of on a tolerance somebody has to keep tuning. The moment you accept a tolerance, you have accepted that a real defect can hide under it.

Part eight: what is actually expensive

(The breakdown in this section is carried over from the original pass rather than re-measured for this update — it is a single-threaded, isolated-microbenchmark methodology that is sensitive enough to machine load that a stale, carefully-built number is more trustworthy than a rushed fresh one. Everything else in this article was re-measured.)

Now the section that puts everything above in perspective — and that I had to measure twice, because the obvious way to measure it is wrong.

The engine carries two probes for exactly this question: -Dnablatensor.jit.randn=zero, which emits a literal 0.0 for every draw instead of reading the RNG buffer, and -Dnablatensor.jit.exp=none, which emits the identity for EXP. Both produce deliberately wrong prices. Both are meant to answer "what would this cost if that part were free?"

One of them does not answer it. Zeroing the draw does not only remove the RNG. With Z a literal zero, vol × Z folds away, the whole log-return drift + vol·Z becomes loop-invariant, and C2 hoists exp(·) clean out of the path loop with it. The probe deletes the generator and both transcendentals, then hands the entire saving to the generator. It reports a number that is almost twice the truth.

So here is the breakdown again, measured with levers that do not fold, and at one thread so there is no turbo or memory-contention confound. One-step European, fp64, price pass:

componentns/pathsharehow it was measured
Philox + Box-Muller15.938%the engine's own generator code lifted verbatim into a standalone loop in the same call shape, 40 M draws, best of 11
the two exp calls11.126%-Dnablatensor.jit.exp=none: 23.6 → 32.0 Mpath/s. The draws stay live, so nothing folds
the tape, plus accumulation15.436%the residual
one price path42.323.6 Mpath/s
the reverse sweep on top26.314.6 Mpath/s for value + five Greeks

Roughly three equal thirds. The random draw is the largest single item at 38%, but it is not the two-thirds the naive probe claimed, and the recorded tape — the thing this entire article is about compiling — is a third of the bill, not a sixth.

Two stacked horizontal bars, single-threaded nanoseconds per path. The price-only path totals 42.3 nanoseconds: Philox plus Box-Muller 15.9 ns (38%), the two exp calls 11.1 ns (26%), and the tape plus accumulation 15.4 ns (36%). The value-plus-five-Greeks path totals 68.6 nanoseconds: the same three components plus a reverse sweep of 26.3 ns, annotated '+62% buys every Greek there is'. A banner notes that a first attempt at this chart put the random number generator at 63% because the probe used to measure it also let the compiler hoist both exp calls out of the loop.

Three roughly equal thirds — the draw, the transcendentals, and the tape. An earlier version of this figure showed the draw at two thirds; the probe it was built on was deleting the exp calls too and charging them to the generator.

One honest test of that attribution: if the draw really is 15.9 ns of a 42.3 ns path, then making the draw 1.74× cheaper should buy about 1.19× on the path. I built that experiment — swapping Box-Muller for an inverse-CDF quantile function, which turns three transcendental calls per draw into one rational polynomial — and measured 1.12× end to end. Close, and short by exactly the amount you would expect: an out-of-order core overlaps part of the draw with the tape, so only about two thirds of any saving on the draw reaches the bottom line. About 65% of it did.

That reframes the exercise without undermining it. The code generator's job was never to make the tape's arithmetic fast — it is the same arithmetic either way. Its job was to make the tape stop costing anything beyond its arithmetic, so that what is left is a third of a path of real floating-point work, a third of a path of transcendentals, and a third of a path of random numbers, all of which you genuinely asked a computer to do.

The RNG that makes all of this parallel. NablaTensor draws with Philox2x32-10, from the Random123 family — Salmon, Moraes, Dror and Shaw's "Parallel random numbers: as easy as 1, 2, 3", which won best paper at SC11 in 2011.

A Philox generator is counter-based and completely stateless. There is no stream to advance, no seed to carry forward: the draw for path p is a pure function of (p, seed), computed by ten rounds of a bijection built from a single 32×32→64-bit multiply. That property is not a nicety, it is the entire architecture of this engine. It is why path 40,000,000 can be generated by whichever worker thread gets to it, in whatever order, without any synchronisation; why the eight-thread run reproduces the one-thread run to fifteen digits; and why the same recording runs unchanged on a GPU where 20,000 lanes need 20,000 independent draws in the same cycle. A Mersenne Twister could not do any of that, because a Mersenne Twister has a place it is up to.

The normals come from Box–Muller: sqrt(-2 ln u₁) times cos(2πu₂) and sin(2πu₂), one log, one sqrt and one sincos per pair of draws. That is George Box and Mervin Muller's 1958 note in the Annals of Mathematical Statistics, which fits on two pages and has outlived every fancier method proposed to replace it.

What to actually use

The full CPU picture on this box, all engines pinned to eight threads for a fair comparison, one fresh JVM per row:

engineprecisionprice onlyvalue + 5 Greeksneeds
cpu — tape interpreterfp6468.7 Mpath/s41.5 Mpath/snothing
cpu-jit — generated bytecodefp64153.292.5JDK 24+
simd — JDK Vector APIfp64406.7207.8--add-modules jdk.incubator.vector

cpu-jit is the default engine, and the reasoning is not that it is the fastest, because it isn't — simd is 2.7× quicker on the price pass. It is the default because of what it doesn't need. No incubator module, no --add-modules on a production command line, no native library, no device driver, no GPU. It runs on a stock LTS JVM in a locked-down container, it is bit-exact against the reference interpreter, and it is twice the interpreter. For a risk system that has to justify every dependency to somebody, that combination is worth more than a factor of two.

Reach past it when you have measured that you need to: simd when the incubator module is acceptable, vulkan or cuda when a laptop iGPU beating both CPU paths by 30× is worth a driver dependency.

And the ranking of things that actually mattered, in order:

  1. Not being interpreted at all. 2.2× over a good tape interpreter, for free, from a compiler with no dependencies.
  2. Emitting methods HotSpot will compile. Up to 5.8× on the adjoint sweep, and the failure is silent, which makes it the one to get right first.
  3. Loop rolling. A few percent on the adjoint sweep of a 252-step tape, noisily, a 144× reduction in class size, and around 830 ms of generation time. Opt-in for a reason.
  4. Everything else. FAST_MATH — hand-rolled polynomial log/sin/cos for the Box-Muller step — is documented in its own Javadoc as measuring slower on Zen 4 than the SVML-backed Math intrinsics it replaces, while also moving the low bits. It ships anyway, disabled, so that the result is on the record rather than folklore.

Try it

Everything here is reproducible from the public repo. The tape dump is four lines against the public API:

var model = Nabla.model(EquityMarket.atmOneYear(),
    (rec, in) -> Products.europeanCall().record(rec, in, TimeGrid.uniform(1)));
AadTape t = model.tape();
for (int i = 0; i < t.size(); i++) {
  System.out.println(i + "  " + t.op(i) + "  a=" + t.argA(i) + " b=" + t.argB(i));
}

To watch the generator make its decisions, set -Dnablatensor.jit.debug=1 and it prints the shape, node count, precision and emitted class size for every kernel it builds. To reproduce the cliff, set -Dnablatensor.jit.seg and walk it upward on a tape of a few hundred nodes; the collapse arrives between 256 and 512 on this payoff and will arrive somewhere else on yours, because where it arrives depends on how much bytecode your adjoint rules emit per node.

Which is, in the end, the moral. The number that governs whether your generated Greeks run at full speed or an order of magnitude below it — the widest gap measured here was 1.256 against 0.110 Mpath/s, a factor of more than eleven — is not in your model, your market data or your maths. It is a bytecode budget in somebody else's compiler, and the only way to find out where you stand against it is to measure.

Source: KernelGenerator.java (the code generator), JitReplay.java (the replay loop), ScalarReplay.java (the interpreter it is measured against) and JitOptimizations.java (what is opt-in and why).


Questions or corrections? open an issue