← learnModule 1 · Your first pricer4 min read

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

A lambda that records ADouble operations runs once through AadRecorder, producing a frozen AadTape stored as parallel arrays — op, argA, argB, constant — in recording order. Every replaying engine then copies that same tape once more into a FlatTape of plain public arrays for its innermost per-scenario loop.

Did you know?

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.

Did you know?

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


Questions or corrections? open an issue