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
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:
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.