← learnChapter 12 · Under the hood7 min read

What a "generated bytecode kernel" actually looks like

Every example since Chapter 1 has called .on("cpu-jit") without asking what that string actually builds. The answer: a real class file, generated once per tape with the JDK's Class-File API, sized and disassembled for real here — 1,243 bytes for the European call, 59,176 for the 252-step Asian, and 1,664 for the exact same Asian tape with its loop rolled up, bit-identical to 17 digits.

Every .on("cpu-jit") call since Chapter 1 has quietly built something: a real Java class, generated at run time, that exists only for one recorded tape. What is actually in it?

The whole story

A pipeline in three boxes. First, the recorded tape: a few real nodes from the 26-node European call — node 14 MUL, node 15 ADD, node 16 EXP, node 17 MUL v18 = v0 times v17. Second, the bytecode KernelGenerator emits for that last node: aload_0, bipush 18, aload_0, iconst_0, daload, aload_0, bipush 17, daload, dmul, dastore — ten instructions, with every array index now a literal baked into the instruction stream. Third, a hidden class implementing a two-method JitKernel interface, forward and reverse, loaded with defineHiddenClass, the same JVM mechanism used for every lambda. Below, a banner on HotSpot's 8,000-byte HugeMethodLimit: a method over that size is never compiled, so the generator splits forward and reverse into segments of at most 128 nodes each by default. A real-numbers table measured this session: the 26-node European call is a 1,243-byte class, the 1,536-node 252-step Asian call is 59,176 bytes flat, and the identical Asian tape with its loop rolled up is 1,664 bytes — 35 times smaller, with price and delta agreeing bit for bit to all 17 printed digits against the flat kernel.

Did you know?

The generated class has no name a class loader will ever resolve. It's a hidden class (JEP 371, JDK 15), built with exactly the call KernelGenerator.generate makes: MethodHandles.lookup().defineHiddenClass(bytes, true).lookupClass(). Nothing can reflect it by name or accidentally hold it alive, and it's unloadable independently of its loader — closing a pricer really does let the kernel go. It isn't an exotic corner of the JVM, either: it's the same mechanism invokedynamic uses to implement every lambda you've ever written. Your JVM has been generating and loading hidden classes since long before this engine asked it to generate one on purpose.

One case per opcode, walked once

KernelGenerator.fwdNode is a switch over the tape's opcodes, one case each, real source:

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); if (!EXP_NONE) 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/loadV go through an interface (Slots), not a fixed emission — that's what lets the same fifteen op-rules serve both today's flat kernel (values in a v[] array) and the rolled kernel later in this page (values in JVM locals) without a second copy of the arithmetic.

Node 17 of the European call from Chapter 1.3 — v18 = v0 * v17, spot times the terminal exponential — compiles to this, javap -c on the real generated class:

aload_0                     // the v[] array
bipush        18            // the destination slot — a literal
aload_0
iconst_0                    // node 0's slot — a literal
daload                      // v[0], the spot
aload_0
bipush        17            // node 17's slot — a literal
daload                      // v[17], exp(drift + vol*Z)
dmul
dastore                     // v[18] = the product

Ten instructions, and every index the tape interpreter (cpu, Chapter 4.1) would fetch out of a parallel array at run time is a constant sitting in the instruction stream instead. The reverse sweep has its own rule per opcode — KernelGenerator.revNode — and every rule is +=, never =, because a node can be read by more than one later node and its adjoint accumulates every contribution:

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]
}

The generated class implements a two-method interface — real source, JitKernel.java:

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

Which is the whole reason this is faster than the interpreter, in one sentence: there is no op left to look up. By the time a path is replaying, the question "what does node 17 do?" was already answered when the class was generated, not a hundred million times while it runs.

The 8,000-byte ceiling

HotSpot's C2 compiler will not compile a method over -XX:HugeMethodLimit — 8,000 bytes of bytecode — ever. There's no warning and nothing throws; the method just runs in the interpreter for the rest of the process, which is worse than the cpu tape interpreter this kernel was generated to beat.

So KernelGenerator never emits one giant forward()/reverse(). It splits both sweeps into fwd$0, fwd$1, …/rev$0, rev$1, … of at most segNodes nodes each — 128 by default (-Dnablatensor.jit.seg=128 is JitReplay's own literal default) — and the public forward/reverse methods are nothing but a list of invokestatic calls into them. Values that cross a segment boundary live in the shared v[]/d[] arrays, which they were already living in.

Setting -Dnablatensor.jit.debug=1 prints exactly what got built. Real output, this session, this machine:

[jit] flat nodes=26 fp64 classBytes=1243 roll=false crn=false fastMath=false
[jit] flat nodes=1536 fp64 classBytes=59176 roll=false crn=false fastMath=false

The 26-node European call from Chapter 1.3 is a 1,243-byte class. The 252-step Asian call from Chapter 3.1 — 1,536 nodes, the same tape 2.1's adjoint-vs-bump comparison used — is 59,176 bytes, comfortably segmented into pieces well under the 8,000-byte line at the default segNodes=128.

💡 Rolling the loop, without changing the answer

The flat kernel above is a full unroll: 252 fixings become 252 copies of a six-node body. JitOptimizations.Level.LOW_RISK turns on a second generator instead — scan the tape for a prologue · body×N · epilogue shape, confirm every repetition is structurally identical, and emit the body once inside a real bytecode loop, with per-iteration values held in JVM locals rather than array slots.

Set -Dnablatensor.jit.roll=on on the exact same Asian tape and the debug line changes:

[jit] rolled(period=6 x251, tape=2) nodes=1536 fp64 classBytes=1664 roll=true crn=false fastMath=false

1,664 bytes against 59,176 — a 35× smaller class, for the same 1,536 nodes. And it isn't an approximation traded for that size: run both and compare to full precision, this session, this machine —

flat:   price=5.30167613351201400  delta=0.56193150014266060
rolled: price=5.30167613351201400  delta=0.56193150014266060

Bit for bit, all seventeen printed digits. The roller only changes where a value lives between one bytecode instruction and the next — array slot versus JVM local — never the arithmetic itself. It's opt-in and off by default (JitOptimizations.NONE) precisely so that the default kernel keeps the flat kernel's simpler promise: match the cpu interpreter, no exceptions.

Try it yourself

Add -Dnablatensor.jit.debug=1 to any Chapter 1–3 example's command line and rerun it — one flag, no code touched. You'll see the same [jit] flat nodes=… classBytes=… line this page prints, for whatever tape that page built. Then add -Dnablatensor.jit.roll=on to the one-step European call from Chapter 1.3: the debug line still says flat, not rolled — a single-step tape has no repeating body for the loop detector to find, so the roller has nothing to do and silently declines. Try it on the 252-step Asian call instead and it collapses to rolled(...), exactly as above.

▶️ Run it

This is the one page in this whole Learn section where the usual rec .input() rewrite doesn't apply, because it doesn't need to fix anything — there's nothing reflective in KernelGenerator to route around. The problem is more basic: KernelGenerator/JitKernel/defineHiddenClass live in com.nablatensor.engine.jit, a package that needs the JDK 24 Class-File API. There is no cpu-engine stand-in for "generate and disassemble a class file" — the class file is what this page is about.

What survives the cut is the tape the generator reads from — reflection- free and engine-independent, the same AadTape.op()/argA()/argB() introspection Chapter 1.1's cell used. This cell walks the real 26-node European call tape from Chapter 1.3, right here, and prints node 18 — v18 = v0 * v17, the exact node the real page's javap -c output disassembles — before saying plainly what it can't do:

Java · compile and run in this browser

Or run the real thing:

mvn -o -q install
mvn -o -q -pl nablatensor-examples exec:java \
  -Dexec.mainClass=com.nablatensor.examples.VanillaEuropeanGreeks \
  -Dnablatensor.jit.debug=1

Prints [jit] flat nodes=26 fp64 classBytes=1243 … ahead of the usual price/Greeks table — the first line on this page, from your own run.

⚠️ What this doesn't do

This page covers the mechanism ROLLED_LOOPS/Level.LOW_RISK uses and the HugeMethodLimit segmentation every kernel gets by default; it doesn't cover JitOptimizations' other two categories — DRAW_CACHE (its own blog article) trades memory to skip re-drawing random numbers on a repeat revaluation, and FAST_MATH trades a few bits of precision for a Math.exp/log/sin/cos replacement that, measured on this project's own hardware, isn't even reliably faster. It also doesn't repeat the full segment-size sweep and the resulting 5.8× throughput cliff on the Greeks pass, or the per-path cost breakdown (Philox, exp, the tape itself, each roughly a third) — those are the deeper article's, not re-derived here. And it stays entirely on the JVM: the GPU code generators (CudaAadCodegen, VulkanAadCodegen) compile the same tape to CUDA-C or GLSL instead of JVM bytecode, and that comparison is Chapter 4.3's.

What's next

→ Deeper: A JIT inside a JIT: how cpu-jit compiles adjoint Greeks into bytecode has the full segment-size sweep — the exact point where the Greeks pass falls 5.8× and the price pass stays flat — plus every opcode's reverse rule and the single-threaded cost breakdown this page didn't repeat. → Next: Writing a custom op — a differentiable building block that needs no change to this generator at all, because it never leaves the primitive vocabulary this page just walked through.


Questions or corrections? open an issue