Recording a tape
AadRecorder, AadTape, and FlatTape — how a handful of ADouble operations becomes one object you can replay, before any finance enters the picture.
One ADouble is one recorded operation. A real payoff touches dozens of
them. What actually holds all those little recordings together into one
thing you can replay?
The whole story
An AadTape isn't a tree of Node objects pointing at their children —
it's a handful of parallel primitive arrays (op[], argA[], argB[],
constant[], ...) indexed by position. Nothing needs sorting: nodes are
appended in the order your code creates them, which is already a valid
order for the forward sweep, and its reverse already works for the
backward one.
The record step: one lambda, run exactly once
Skip finance entirely — this is the smallest tape that isn't trivial, the
same (x+2)*x from adjoint AD for dummies,
written the way application code actually writes it:
AadTape tape = AadRecorder.record(rec -> {
ADouble x = rec.input("x", 3.0);
ADouble a = x.add(2.0);
rec.output(a.mul(x));
});
public static AadTape record(Consumer<AadRecorder> body) {
AadRecorder recorder = new AadRecorder();
body.accept(recorder);
return recorder.builder.build();
}
record makes a fresh recorder, runs your lambda exactly once — with
x at its recorded value, 3.0 — and hands back whatever .build()
produces once every ADouble operation inside the lambda has appended its
node. Four nodes come out of these three lines: x.add(2.0) is secretly two
operations (a CONST node for the literal 2.0, then the ADD), so the
tape ends up INPUT, CONST, ADD, MUL — exactly the four boxes in the
picture above.
Walking the tape you just built
AadTape exposes that array by position, not by name — op(node),
argA(node), argB(node) — so you can read a recording back the same way
the reverse sweep does:
for (int i = 0; i < tape.size(); i++) {
System.out.println(i + ": " + tape.op(i));
}
// 0: INPUT
// 1: CONST
// 2: ADD
// 3: MUL
Which nodes actually need an adjoint
Not every node costs something in the reverse sweep. AadTape marks each
one active or not the moment it's built:
private boolean[] markActive() {
boolean[] flags = new boolean[op.length];
for (int i = 0; i < op.length; i++) {
flags[i] = switch (op[i]) {
case INPUT -> true;
case CONST, RANDN, RANDU -> false;
case NEG, EXP, LOG, SQRT, ABS -> flags[argA[i]];
default -> flags[argA[i]] || flags[argB[i]];
};
}
return flags;
}
A node is active when its value traces back to at least one INPUT —
that's it. Constants and random draws start inactive, and inactivity spreads
forward through anything built only from them, so a tape with a lot of
market-data constants can skip most of them in the reverse sweep for free.
"Active" means depends on an input, not reaches an output. A node built
from x that never flows into rec.output(...) is still active — it still
gets an adjoint slot and still costs time in the reverse sweep — because
nothing here checks whether it was ever used downstream. AadTape prunes
the constant/random subgraph for you; it doesn't prune dead code.
Try it yourself
Add one more line to the lambda above — something derived from x that
never reaches rec.output(...):
ADouble unused = x.mul(x).mul(x);
Guess, then check: does tape.size() grow? Does tape.isActive(...) on
unused's node come back true or false? (It grows by two nodes, and
yes, active — see the sidenote above.)
FlatTape: the same tape, copied once more for the loop
Every engine replays the same tape millions of times, and AadTape.op(int)
is a method call — a bounds check and a field load the JIT won't hoist out
of a per-scenario inner loop no matter how hot it gets. FlatTape exists to
remove that call: it's the identical data, copied once into plain public final arrays, so the sweep reads a single array slot instead of going
through an accessor. It's marked @Internal — application code never
touches it; it's plumbing for whoever implements an engine.
▶️ Run it
Still nothing to run against real scenarios — this tape has one input, one
output, and no engine attached to it yet. Module 1.3 is where
MonteCarlo.of(...).on("cpu-jit").build() takes a tape like this one and
actually replays it.
⚠️ What this doesn't do
This page stops at a tape that exists in memory. It doesn't show a forward
sweep producing a value, a backward sweep producing a gradient, randn()/
randu() turning into scenarios, or a single one of the six engines — all
of that needs a tape to already exist, which is exactly what this page
built.
What's next
→ Deeper: AadTape.java
has the rest of the API this page didn't need — named outputs, multiple
random streams, recordedInputs() — everything a replay engine actually
reads.
→ Next: Replaying it, turning a tape shaped exactly
like this one into a price, delta, vega, and rho — for a real option.