← blog29 min read

Forget, then remember: checkpointing the adjoint sweep on a GPU

On the 252-step Asian tape, the Vulkan engine's Greeks pass ran eight times slower than its price pass, where the CPU engines lose only 1.7x. The cause: each path held ~760 forward values for the reverse sweep, more room than a GPU lane has. The fix is checkpointing — keep 38 bookmarks, recompute each segment when the reverse sweep needs it. 2.9x on the Greeks ladder, 6.8x once four markets share one dispatch, same seventeen digits.

gpuvulkanopenclcudaautodiffgreekscheckpointingmonte-carlobenchmarks
Infographic titled 'Forget, then remember'. Left panel, the plain kernel: one GPU lane's registers drawn as a full grid of red squares, overflowing into a box labelled scratch memory, DRAM, 3 KB per path times thousands of paths; below it, 252-step Asian at 300,000 paths, price only 125 million paths per second, value plus five Greeks 15 million paths per second, eight times slower, versus 1.7 times on a CPU. Right panel, the checkpointed kernel: the tape cut into 16 segments with a bookmark at each cut holding the three or four values that cross plus the five inputs; an arrow running right to left labelled reverse sweep, last segment first, re-read the 96 nodes from the bookmark, dice included, run that segment's adjoint, forget, step left; registers drawn as 38 bookmarks plus one segment with most squares empty; value plus five Greeks 46 million paths per second, 2.9 times, and 6.8 times with four markets per dispatch. Bottom: a bar chart of milliseconds per seven-point Greeks ladder, plain kernel 138, checkpointed 47 (2.9 times), checkpointed plus four markets per dispatch 20 (6.8 times). Footer: all three kernels return the same seventeen digits, price 5.3010181964111330, delta 0.56163036491394050, vega 22.385515967610676.

Here is a number that should not exist.

Take the 252-step arithmetic Asian call from the previous articles — 1,536 tape nodes, 252 normal draws per path — and run it on the Vulkan engine, one path per GPU lane. Price only, it does 125 million paths per second. Ask for the value and its five Greeks from the same tape, and it does 15. Eight times slower. On the CPU engines the same request costs 1.7×, and the bytecode article explained why it cannot reasonably cost less: the reverse sweep is a second walk over the tape, roughly as long as the first.

So where did the other 6× go? Not into arithmetic. Into remembering. And the fix — which is the whole of this article — is to remember less: keep 38 numbers per path instead of 760, throw everything else away the moment it is used, and compute it a second time when the reverse sweep comes back for it. That makes the Greeks pass 2.9× faster on its own and 6.8× faster once four markets share a dispatch, returns the same seventeen digits as the kernel it replaces, and switches itself on only for tapes long enough to need it.

Every number below was measured on referential machine — the same Ryzen 7 8845HS as the previous articles, this time on its integrated Radeon 780M through Vulkan (RADV), fp32 — as the median of three seven-point spot ladders, one fresh JVM per row.

Part one: what a Greeks pass has to remember

A tape is a receipt. Recording the Asian call writes down every arithmetic step the price took: node 15 is a random draw, node 16 multiplies it by vol·√dt, node 17 adds the drift, node 18 takes the exponential, node 19 multiplies by the running spot, node 20 adds it to the running sum — and then the same six lines again, 251 more times, and nine lines of payoff at the end. The forward sweep reads the receipt top to bottom and produces a price.

The reverse sweep reads it bottom to top and produces the Greeks. It carries one number per node — call it the node's blame: how much the final price would move if that node's value moved by a tiny amount. The last node's blame is 1, by definition. Every other node's blame is computed from the blame of the nodes that used it, by a rule that depends on the operation. And the rules need some of the forward values back:

forward nodeto pass blame back to its inputs, the reverse sweep needs
c = a + bnothing — the blame splits evenly
c = a × ba and ba's share is scaled by b, and vice versa
c = exp(a)c itself — the slope of exp is exp
c = a / bb and c
c = max(a, b)a and b — to know which one won

Count what that means for one time step of the Asian. The multiplication by vol·√dt needs the draw. The exponential needs its own output. The multiplication by the running spot needs the running spot. Three numbers per step; 252 steps; about 760 forward values that the plain kernel has to keep from the moment they are computed until the reverse sweep, hundreds of nodes later, reads them.

On a CPU that is a non-event. The cpu-jit engine keeps the whole tape in a double[] of 12 KB, one path at a time per thread, and 12 KB is a fraction of the core's first-level cache. The GPU is a different shape.

Part two: a GPU lane's pockets

A GPU gets its speed from running thousands of paths at once, one per lane, and switching between them whenever one is waiting for something. Each lane has a small private store — its registers — and the arithmetic is fast precisely because it only ever reads and writes those. A lane gets at most a few hundred 32-bit registers, and the more a kernel uses, the fewer paths the scheduler can keep in flight to cover each other's waits.

Ask a lane to hold 760 values and it cannot. The compiler does the only thing it can: it spills — writes the overflow to "scratch", a private slice of main memory with a small cache in front, and reads it back when the reverse sweep asks. One path spilling 3 KB is nothing. Every lane on the chip spilling 3 KB at the same time, then all of them reading it back in reverse order, is a memory workload dressed up as an arithmetic one, and memory is what a GPU has least of. The 8× is that.

You can see the exact moment it happens by making the tape longer:

Bar chart titled 'The cliff, and where recomputing starts to pay': value plus five Greeks on the Asian call, Vulkan fp32, million paths per second. Five groups by tape length, each with a red bar (plain kernel) and a green bar (checkpointed). 50 steps, 324 nodes: 196 versus 166, checkpointed 0.85x. 100 steps, 624 nodes: 156 versus 132, 0.85x. 150 steps, 924 nodes: 52 versus 101, 1.9x. 200 steps, 1,224 nodes: 25 versus 77, 3.1x. 252 steps, 1,536 nodes: 15 versus 46, 3.1x. A dashed line between the second and third group with annotations: to the left, the plain kernel still fits in registers and recomputing only costs, minus 15 to 18 percent; to the right, somewhere between 624 and 924 nodes it spills, and every extra step makes the fall steeper. Footnote: one million paths per call, the 252-step column at 300,000; the engine switches checkpointing on at 768 nodes by default.

Same product, same market, more time steps. Between 624 and 924 nodes the plain kernel runs out of registers and its throughput falls off a cliff — 156, then 52, then 25, then 15. The green bars are the kernel this article is about.

At 50 and 100 steps the plain kernel is fine: everything fits, and it is the fastest thing on the chart. At 150 steps it has lost two thirds of its speed; at 252, ninety percent. Nothing about the arithmetic changed. The tape just got too long to hold.

Part three: bookmarks, and re-reading

Here is the idea, without the maths first.

Suppose you are going to be quizzed on a novel, chapter by chapter, in reverse order — chapter 16's questions first, then chapter 15's, and so on back to chapter 1. One way to prepare is to memorise the whole book. The other is to read it once, leave a bookmark at the start of each chapter, and forget the pages. When chapter 16's questions come, open the book at bookmark 16, re-read that one chapter, answer, close it. Then bookmark 15. At any moment you are holding sixteen bookmarks and one chapter — never the book.

Two rows. Top, the plain kernel, memorise every page: sixteen chapter blocks all filled red, captioned everything in your head at once, ~760 values per path; a lane has room for a few hundred, the rest goes to scratch memory and the quiz waits on every fetch. Bottom, the checkpointed kernel, bookmarks then re-read: sixteen white chapter blocks each with a green bookmark tab, chapter 16 shown open in blue, an arrow running right to left captioned the quiz moves this way, open chapter 16, answer, close; open chapter 15, answer, close. Text: in your head at any moment, 16 bookmarks and one open chapter; here a bookmark is three to four numbers and a chapter is 96 tape nodes, so 38 values plus one segment fits in the registers. The price: every chapter is read twice. Only possible because a chapter reads the same the second time — the dice are a pure function of (path, k), so they come back identical.

The whole technique. The tape is the novel, the reverse sweep is the quiz, a segment is a chapter, and the values that cross a cut are the bookmark.

The price is obvious: every chapter is read twice. The forward sweep runs once to produce the price and the bookmarks, and then once more, a segment at a time, inside the reverse sweep. On a CPU that would be a bad trade — you would pay a second forward sweep to save memory you were not short of. On a GPU lane that is spilling, it is the best trade available: arithmetic is the thing the lane has in abundance, and memory traffic is the thing that was costing 8×.

There is one condition, and it is the same property the draw cache was built on. A chapter has to read the same the second time. Every node in the tape is a function of the nodes above it, except one kind: the random draws. If the draws came from a generator with internal state — "give me the next number" — then re-reading a chapter would need that state saved at every bookmark, or it would produce different dice and a different, wrong, adjoint. NablaTensor's GPU kernels use Philox, a counter-based generator: draw k of path p is a pure function of (p, k), computed from scratch every time it is asked for, and the comment on the generator has said since it was written that this is so "the adjoint sweep can recompute a checkpointed segment's randoms without saving RNG state". That sentence sat there for a while before anything used it.

Did you know?

The technique is thirty years old and trains the largest models there are. Andreas Griewank published it in 1992 as Achieving logarithmic growth of temporal and spatial complexity in reverse automatic differentiation, and the 2000 paper with Andrea Walther gave it the name most people know, revolve: with s bookmarks and the willingness to recompute t times, you can reverse a chain of length (s+t choose s) — so a handful of each covers a tape of any practical length. Deep learning rediscovered it in 2016 under the name gradient checkpointing (Chen, Xu, Zhang and Guestrin, Training deep nets with sublinear memory cost), where the "tape" is a neural network's layers and the "forward values" are its activations. Every large language model trained today throws away most of its activations after the forward pass and recomputes them layer by layer during the backward pass, for the same reason as this article: the accelerator has arithmetic to spare and memory it does not.

Part four: where to cut

The analogy hides one question: what, exactly, is a bookmark? In a novel it is a page number. In a tape it is the set of values computed before the cut that some node after the cut still reads. Those have to survive; everything else in the segment can be recomputed from them.

The interesting fact about the Asian tape — and about most Monte-Carlo tapes — is how small that set is. Look at three time steps' worth:

Chart titled 'What crosses a cut'. The 42-node tape of a 3-step Asian call drawn as a strip of coloured boxes — inputs in indigo, constants in pale lilac, draws in orange, exponentials in green, other arithmetic in grey — grouped as inputs; drift and vol times root dt; step 1; step 2; step 3; average, payoff, discount. Above it two step curves: a blue one, values the forward sweep still needs at this point, staying between 7 and 8 across the whole tape; and a red shaded one, forward values the reverse sweep will need from before here, climbing steadily to 23 by the end — 3 per step here, about 760 on the 252-step tape, and the plain kernel keeps all of it. Dashed green cut lines at each step boundary labelled cut: 7 cross, 7 cross, 7 cross, 4 cross. A panel below: at the cut before step 2, seven numbers cross — strike, rate, maturity, drift, vol times root dt, the running spot, the running sum. The forward sweep is a narrow pipe; only the reverse sweep is wide.

Two counts along the same tape. What the forward computation still needs at any point is flat: seven or eight values, at any tape length. What the reverse sweep will come back for grows with every step. A checkpoint holds the first; the plain kernel holds the second.

At the boundary between step 1 and step 2, exactly seven numbers cross: the strike, rate and maturity (inputs the payoff will need), the drift and vol·√dt (computed once, used by every step), and the two things a step actually carries forward — the running spot and the running sum. It is seven at the next boundary, and the one after that, and at every boundary of the 252-step tape. The forward computation is a narrow pipe. Only the reverse sweep's appetite is wide, and the whole trick is to feed that appetite segment by segment instead of all at once.

The code that finds the cuts is short. For every node it records the last node that reads it; then for each of the fifteen cut positions it looks in a window around the evenly spaced target and picks the spot where the fewest values are still alive:

int[] lastUse = new int[n];
for (int i = 0; i < n; i++) {
  if (refsA(tape.op(i))) lastUse[tape.argA(i)] = i;
  if (refsB(tape.op(i))) lastUse[tape.argB(i)] = i;
}
for (int s = 1; s < segments; s++) {
  int target = (int) ((long) s * n / segments);
  int window = Math.max(1, n / (4 * segments));
  // best cut in [target - window, target + window]: fewest j < c with lastUse[j] >= c
  ...
}

Everything that crosses any cut — plus the inputs and the output — is marked persistent and declared once, at the top of the path loop. Everything else is local to its segment. The generated GLSL then has this shape, with the real node numbers from the 252-step tape:

// 38 persistent values live for the whole kernel
float v0; float v1; ... float v11; float v13; ... float v1535;

// forward sweep: sixteen blocks; a block's locals die at its closing brace
{
  v0 = in0;  v1 = in1;  v2 = in2;  v3 = in3;  v4 = in4;
  float v5 = v2 * v2;
  ...
  v11 = v8 * v10;          // drift          — persistent
  v13 = v2 * v12;          // vol·√dt        — persistent
  float v15 = rng_normal(0u);
  float v16 = v13 * v15;
  float v17 = v11 + v16;
  float v18 = exp(v17);    // needed by the reverse sweep — and forgotten
  float v19 = v0 * v18;
  float v20 = v14 + v19;
  ...
}
{ ... }   // × 15 more

A block's local variables go out of scope at its closing brace, and the compiler frees their registers. That is the "forget". Six lines per step, and at the end of the forward sweep the lane is holding 38 numbers.

Then the reverse sweep, last segment first. Each block re-runs its segment's forward lines — rng_normal(238u) produces the identical draw it produced the first time, because (path, 238) is all it depends on — then runs the segment's adjoint rules against those freshly recomputed values:

d1535 = 1.0;                       // the price's blame is 1
{                                  // segment 16
  rng_init((pathLo ^ pc.seedLo) ^ pc.zero, (pathHi ^ pc.seedHi) ^ pc.zero);
  float v11 = opq(v11);            // the bookmark, read back
  float v13 = opq(v13);
  float v1436 = opq(v1436);        // running sum at the cut
  float v1441 = opq(v1441);        // running spot at the cut
  float v1443 = rng_normal(238u);  // the same dice as the first time
  float v1444 = v13 * v1443;
  float v1445 = v11 + v1444;
  float v1446 = exp(v1445);        // recomputed, not remembered
  ...
  float d1444 = 0.0;  float d1445 = 0.0;  ...
  d1531 += d1535 * v1534;          // the adjoint rules, highest node first
  d1534 += d1535 * v1531;
  ...
}
{ ... }                            // segment 15, then 14, down to 1

The blame of a persistent node — d11, d13, the inputs' — is declared at the top level so that it accumulates across segments; the blame of a local node is declared inside its block and dies with it. When the last block closes, d0 through d4 hold the five Greeks, and they are added into the per-invocation Kahan sums exactly as before.

Part five: why there is an XOR in the reverse sweep

Two lines in that listing look odd: the opq(...) wrappers on the bookmark values and the ^ pc.zero on the generator's key. They are the one piece of the mechanism that is about the compiler rather than the maths, and the kernel does not work without them.

Two panels titled 'Why there is an XOR in the reverse sweep'. Left, without it, the compiler merges the two sweeps: the forward sweep's segment 3 computes v21 = rng_normal(1u) and v24 = exp(v11 + v13 * v21) then forgets; the reverse sweep's segment 3 recomputes the same two lines with the same inputs and the same expression; common-subexpression elimination says these two are the same value, compute it once, keep it, reuse it — correct, and it puts every forward value back on the live list for the whole kernel, the spill is back, and the generated source looks exactly like it should. Right, with it, the recomputation stays a recomputation: one push constant, always zero, that the compiler cannot see — float opq(float x) returns uintBitsToFloat(floatBitsToUint(x) XOR pc.zero); the reverse segment re-keys the dice with pc.zero, reads the bookmark through opq, and now v21 and v24 have different inputs and are a different expression. x XOR 0 is x bit for bit, but only at run time; at compile time pc.zero is an unknown 32-bit value so nothing merges. One XOR per bookmark value per segment; same seventeen digits.

A recomputation is, by construction, the same expression as the original, and merging identical expressions is one of the first things any optimising compiler does. The XOR with a push constant that is always zero makes the two chains look different without being different.

A recomputed segment is, by construction, the same arithmetic on the same inputs as the first time round. That is precisely the pattern an optimising compiler is built to find: "these two expressions are identical — compute it once, keep the result, use it twice." Common-subexpression elimination is correct, it is on by default in every shader compiler, and applied here it puts every forward value straight back on the live list for the whole kernel. The generated source is perfectly checkpointed and the compiled kernel is the plain one again.

The way out is to make the recomputation look different while being the same. The pipeline's push-constant block carries one extra uint, zero, which the host sets to 0 on every dispatch. Every value read from a bookmark passes through

float opq(float x) { return uintBitsToFloat(floatBitsToUint(x) ^ pc.zero); }

and the generator is re-keyed with key ^ pc.zero at the top of each reverse block. At run time x ^ 0 is x, bit for bit — not "approximately", not "up to rounding"; the same 32 bits, including the sign of a zero. At compile time pc.zero is an unknown value that arrives with the dispatch, so v11 ^ pc.zero is not v11, the chain built on it is not the original chain, and nothing merges. One XOR per bookmark value per segment, and the recomputation stays a recomputation.

Did you know?

Common-subexpression elimination is older than most of the people using it. The global form — across a whole procedure, not just within one statement — was described by John Cocke in 1970, in a paper titled simply Global common subexpression elimination, and the data-flow framework it introduced is still the skeleton of the optimisation in GCC, LLVM and every GPU shader compiler. It is so fundamental that compilers apply it before they apply almost anything else, which is why a technique whose entire purpose is to compute the same thing twice has to hide that fact from them.

Part six: what it is worth

The methodology is the one from the draw-cache article: one kernel, warm on a different seed, then seven calls with only spot changed, three such ladders, median.

The 252-step Asian, 300,000 paths, value and five Greeks:

kernelms per seven-point ladderMpath/svs plain
plain13815
checkpointed, 16 segments47462.9×

The segment count barely matters. Two segments, four, eight, sixteen, thirty-two, sixty-four, a hundred and twenty-six: every one of them lands between 47 and 50 ms. That is the signature of a memory problem rather than an arithmetic one — the cost was never "how much do you recompute", it was "does the working set fit", and any cut at all makes it fit. Sixteen is the default because it is comfortably inside the flat region on this tape and keeps the compiled shader small; the checkpointed kernel takes about three seconds to compile against one for the plain one, paid once per process and cached by the driver after that.

Below the cliff, the same kernel costs: 0.85× at 50 and 100 steps, because the second forward sweep is pure overhead when the first one never spilled. So checkpointing is not always on. The engine switches it on when a tape has at least 768 nodes — the middle of the gap between the last length that fits and the first that does not, on this tape — and leaves shorter tapes on the plain kernel. That threshold, and the segment count, are the two knobs in the next section.

Part seven: it computes the same number

A recomputation that returned different bits would be a bug, not an optimisation, and it would be an easy one to write: a draw regenerated with the wrong index, a bookmark read one segment early. So, spot 100, seventeen significant digits, the plain kernel against the checkpointed one:

252-step Asian, 300,000 paths, Vulkan fp32
kernel           price               delta                vega
plain            5.3010181964111330  0.56163036491394050  22.385515967610676
checkpointed     5.3010181964111330  0.56163036491394050  22.385515967610676

Identical. Not to a tolerance — the same double, because every float partial that went into it was the same float. The forward values that the reverse sweep reads are recomputed by the same instructions from the same inputs, the dice are a pure function of (path, k), and the XOR that keeps the compiler honest is the identity on every bit pattern. The engine's tests can assert == here, and do.

Sharing the dice across a ladder

There is a second thing in this release that composes with the first, and it is the draw cache's idea in the form a GPU actually likes.

A revaluation ladder — seven spots, one seed — asks the generator the same question seven times. The CPU engines answer that with a memo table in RAM. On a GPU the memory is the scarce thing, so the Vulkan kernel does something else: VulkanAadKernel.replayMany takes several input sets at once and prices all of them per path inside one dispatch, generating each path's draws exactly once and holding them in registers while the tape runs for market 1, market 2, market 3. The draws are never written anywhere; there is nothing to cache because nothing is asked for twice. Two things fall out of it: the dice are paid for once per ladder rather than once per market, and the GPU gets several independent chains of arithmetic per lane to interleave, which it is very good at.

tapepassone call per marketfusedvs
European, 200M pathsprice101 ms25 ms (7 markets)4.0×
European, 200Mvalue + 5 Greeks115 ms41 ms (7 markets)2.8×
Asian 252, 1M pathsprice58 ms16 ms (7 markets)3.6×
Asian 252, 300k, checkpointedvalue + 5 Greeks47 ms20 ms (4 markets)2.3×

That last row is the headline: 138 ms for the plain Greeks ladder, 20 ms for the checkpointed one with four markets per dispatch — 6.8×, and still the same seventeen digits. Register pressure grows with the number of markets, so on the long tape four per dispatch is the sweet spot; the one-draw European is happy with all seven. replayMany is an engine-level method today — the Nabla model API still issues one call per market, and a ladder request on it is the next step.

Part eight: three more engines, and the two bugs that made them look hopeless

vulkan is not the only engine with a checkpointed kernel. cuda, opencl and rocm all share one implementation — CudaAadCodegen.generateCheckpointed — compiled three different ways: NVRTC for cuda, HIPRTC for rocm unchanged, and a small dialect rewrite for opencl. Naively benchmarked on the same 252-step Asian tape, all three came back with the same answer, and it was not a good one:

enginehardwaresettled, plainsettled, checkpointedvs plain
openclreferential machine's Radeon 780M69.9 ms80.8 ms0.87×
rocmreferential machine's Radeon 780M73.5 ms80.5 ms0.91×
cudaa Colab Tesla T425.2 ms28.1 ms0.90×

(1,000,000 paths/dispatch, fp32, value + 5 Greeks. opencl and rocm share a tape and a machine with the Vulkan numbers above; cuda ran on a discrete datacenter GPU with sixteen gigabytes of its own dedicated memory — about as far from referential machine's memory-starved APU as GPU hardware gets.)

Checkpointing was not a small win on any of them. It was a consistent, repeatable loss, by roughly the same 9–13%, on three GPUs from two vendors, one of which has no shared memory bus to blame. That consistency is itself a clue: whatever is wrong lives in the shared code, not in any one driver.

The obvious explanation is wrong. The tempting story is "Vulkan recomputes into registers and never touches memory; the others round-trip a scratch buffer, so of course they lose" — true as far as it goes, but it predicts the size of the loss should shrink on hardware with bandwidth to spare. It does not: the T4's dedicated GDDR6 loses by the same margin as this box's integrated, memory-starved APU. Bandwidth was not the differentiator it looked like.

The scratch buffer is laid out backwards

generateCheckpointed gives every GPU thread a private, contiguous slice of the scratch buffer:

const unsigned long long srow = tid * SLOTS_PER_PATH;
...
scratch[srow + slot] = v1441;          // write a checkpointed value
...
float v1441 = scratch[srow + slot];    // read it back, later, in reverse

That is an array of structures: thread 0's checkpoint sits at [0, SLOTS_PER_PATH), thread 1's at [SLOTS_PER_PATH, 2·SLOTS_PER_PATH), and so on. It reads naturally — "here is thread tid's little bundle of saved values" — and it is exactly the layout a GPU's memory system is built to punish. Thirty-two threads in a warp execute scratch[srow + slot] on the same instruction, at the same slot, so their addresses are tid · SLOTS_PER_PATH apart — for this tape, 90 floats, 360 bytes. A memory controller wants those thirty-two addresses inside one 128-byte line; instead they land thirty-two different lines apart, and the load that should be one transaction becomes up to thirty-two.

The fix is the standard one: lay the buffer out structure of arrays instead — every thread's slot-k value adjacent to the next thread's slot-k value — so a warp's access to one slot is one contiguous 128-byte read instead of thirty-two scattered ones:

- const unsigned long long srow = tid * SLOTS_PER_PATH;
- ...
- scratch[srow + slot] = v1441;
+ scratch[(unsigned long long) slot * invocations + tid] = v1441;

invocations — the total thread count across the launch — was already a kernel parameter, put there to size the buffer; it just was not being used to shape it. One line changes, in three places, in one file shared by all three engines.

Bar chart. Four bars, value plus five Greeks, 252-step Asian call, 1,536 nodes, 1,000,000 paths per call, OpenCL fp32 on an AMD Radeon 780M. Plain kernel (reference): 13.3 Mpath/s. Checkpointed as shipped, array-of-structures scratch, non-coalesced, 32 segments: 12.2 Mpath/s, 0.92x vs plain. Plus coalesced layout, structure-of-arrays scratch, still 32 segments: 12.9 Mpath/s, 0.97x vs plain. Plus retuned segments, structure-of-arrays scratch, 8 segments instead of 32: 17.2 Mpath/s, 1.30x vs plain. A bracket over the last three bars reads: two independent fixes, same kernel, same hardware, 0.92x to 1.30x. Footnote: median of 25 timed calls after 20 warm-up calls, one fresh JVM per bar; all four bars return the same price and Greeks to every digit fp32 carries; segment length swept 24 to 800, 200, about 8 segments for this tape, was the measured optimum against the default 48, about 32 segments.

Coalescing the scratch buffer alone recovers about a third of the gap. It does not close it. The second fix does.

Rebuilt and re-measured, right there: the loss shrinks from 8% to 3%. Real, and not nearly enough.

Every segment boundary has a fixed cost the plain kernel does not

Part six of this article measured Vulkan's segment count and found it did not matter: "two segments, four, eight, sixteen, thirty-two... every one of them lands between 47 and 50 ms." That is true for Vulkan, and it is why the number sixteen never got a second thought. It is not true here, because Vulkan's checkpointing has no scratch buffer to argue with. Every extra segment boundary in the register-recompute kernel costs a few more lines of ALU; every extra segment boundary in the scratch-buffer kernel costs a full round trip to memory, coalesced or not. AadCheckpointPlan's segment length defaults to max(48, √n) — 48 for this 1,536-node tape, which cuts it into 32 segments. That default reads like it was chosen for a cost model where segment count is nearly free. It is not free here; it is the second knob nobody had turned.

Sweeping -Dnablatensor.checkpoint.segLen on the now-coalesced kernel:

segLensegmentssettledvs plain
246498.4 ms0.77×
48 (default)3277.3 ms0.97×
1001660.6 ms1.24×
200858.1 ms1.30×
300669.9 ms1.08×
400477.1 ms0.98×
600377.7 ms0.97×
800270.8 ms1.06×

Fewer, bigger segments amortise the fixed per-boundary cost over more useful recomputation, and the optimum for this tape sits at eight segments, not Vulkan's own default of sixteen for the same 1,536 nodes — the two kernels pay for a boundary in different currencies, so there is no reason to expect the same segment count to suit both. The shape past the optimum is not a clean curve: 800 (two segments) ticks back up slightly, most likely because at that point almost nothing crosses a cut at all — slotsPerPath drops to zero, meaning that configuration barely touches the scratch buffer in the first place, a different regime from the rest of the sweep. The unambiguous part is the left half: too many small segments is far worse than too few large ones.

Coalesced layout and a segment length chosen for this cost model, together, on the same tape, same machine, same harness that measured every other number in this section:

opencl, 252-step Asian, 1,536 nodes, 1,000,000 paths, fp32
kernel                              settled     Mpath/s   vs plain
plain                               75.4 ms     13.3      —
checkpointed, as shipped            81.8 ms     12.2      0.92×
+ coalesced scratch buffer          77.3 ms     12.9      0.97×
+ segLen 200 (8 segments)           58.1 ms     17.2      1.30×

Bit-exact against the plain kernel at every step, on every configuration — the fixes touch addressing and segment boundaries, not arithmetic.

So it was never "Vulkan-only"

It never was, mechanically. Checkpointing's saving does not care which GPU API compiled the kernel; what it needs is a working set that genuinely spills and a way to shrink it that costs less than the spill did. cuda, opencl and rocm had that too. What they did not have was a scratch buffer anyone had checked for coalescing, or a segment length tuned for a strategy that, unlike Vulkan's, pays a real price for every boundary it draws.

I verified the fix end to end — bit-exact prices and Greeks, twice, back to back — on opencl on referential machine, because that is what was asked. I have not re-run it on cuda or rocm. But the kernel source touched by both fixes is character-for-character the same file those two engines compile unchanged, and both showed the identical pre-fix loss (0.90–0.91×) that opencl showed before either fix. There is no honest reason to expect a different answer there — that is a prediction, though, not a measurement, and it is marked as one until someone runs it.

Neither change is in the engine as shipped. CudaAadCodegen.java's scratch indexing and AadCheckpointPlan's segment-length default are exactly as they were before this article was written; what exists right now is a diagnosis and a demonstrated fix, not a merged one.

What to actually use

This part is vulkan-specific; cuda, opencl and rocm checkpointing is off by default and stays off — Part eight's fixes are a diagnosis, not a config flag, until someone lands them.

Nothing to switch on for vulkan. A kernel built from a tape of 768 nodes or more gets the checkpointed adjoint sweep automatically; shorter tapes keep the plain one. The knobs, for when a tape does not look like this one:

-Dnablatensor.vulkan.ckpt=<segments>   # 1 switches it off; any n > 1 forces it on with n segments
-Dnablatensor.vulkan.ckpt.nodes=<n>    # the automatic threshold, default 768
-Dnablatensor.vulkan.dump=<dir>        # write the generated GLSL, to see the cuts

If your product's per-step state is much wider than the Asian's — a basket with twenty underlyings carries twenty running spots across every cut — the bookmark is bigger, the cliff comes sooner, and lowering the threshold is the first thing to try. If it is narrower, the default is already right.

The public API is unchanged:

try (var mc = MonteCarlo.of(Products.asianCall())
        .market(EquityMarket.atmOneYear()).steps(252)
        .greeks().fp32().on("vulkan").build()) {
  var r = mc.run(300_000, 42L);        // checkpointed: the tape has 1,536 nodes
  System.out.println(r.price() + "  delta " + r.greek(EquityMarket::spot));
}

The ranking of what mattered, in the order it mattered:

  1. Noticing that the Greeks pass was memory-bound. An 8× gap where the CPU shows 1.7× is not an arithmetic gap, and no amount of tuning the arithmetic would have found it.
  2. The draws being a pure function of (path, k). Without it, re-reading a segment would need the generator's state saved at every bookmark, and the memory saving would be a fraction of what it is.
  3. Cutting where the pipe is narrow. Seven values cross a step boundary of this tape. Fifteen cuts of seven collapse to 38 persistent values once the shared ones are counted once; 38 fits.
  4. One XOR. Without it the compiler merges the recomputation into the original and the kernel is bit-for-bit the plain one, at the plain one's speed. A technique that works by computing the same thing twice has to say so in a way the compiler cannot hear.
  5. Checking the arithmetic before believing the excuse. "Vulkan recomputes into registers, the others round-trip memory, so of course they lose" sounds complete, and it predicted nothing that held up against a Tesla T4 with VRAM to spare. The real reasons — an uncoalesced buffer and a segment length tuned for the wrong kernel's cost model — were both sitting in the source the whole time.

Which is the moral, and it is a GPU-shaped one. Every previous article on this site made something faster by not computing it twice — compile the tape, cache the draws, run the adjoint once instead of bumping. This one made something three times faster by computing it twice on purpose, because the thing being saved was not arithmetic. Know which resource you are short of before you start conserving one.

And a second moral, arrived at by re-deriving the first one badly before getting it right: a tidy explanation is not the same as one that predicts a number correctly on hardware you have not measured yet. "It only works on Vulkan" was tidy, and wrong. The two actual bugs were untidy — one line of address arithmetic, one mistuned default — and the only way to tell the tidy story from the true one was to open the file and run it.

Source: VulkanAadCodegen.java (the cut selection in emitCheckpointed, the segment blocks, opq, and the Philox generator whose comment promised this), VulkanAadKernel.java (the automatic threshold, the zero push constant, and replayMany), and CudaAadCodegen.java (generateCheckpointed's scratch-buffer indexing, shared by cuda, opencl and rocm) and AadCheckpointPlan.java (the segment-length default) for Part eight.


Questions or corrections? open an issue