Bump-and-revalue is quietly costing you 10x

See why five central-difference Greeks need 11 valuations. Compare adjoint AD timings, understand the benchmark estimate, and validate Greeks interactively.

autodiffbenchmarksgreeks
Two panels. Left: 'bump-and-revalue' — a base price box plus five rows of two shifted repricings, one row per Greek, marked 11x full repricings. Right: 'adjoint' — one forward pass and one reverse sweep fanning out to all five Greeks, marked 1x. Below, two wall-clock bars: adjoint 1.13 s against bump-and-revalue 10.86 s, a 9.6x gap.

Updated 21 October 2026. Code examples now use Quant 0.4.0. The timing tables retain the original NablaTensor measurements; SIMD now needs the optional jax4j-simd artifact as well as the Vector API flag.

Central bump-and-revalue needs two shifted valuations per Greek; including a base price, five Greeks require eleven price-only runs. Adjoint automatic differentiation obtains the price and all five first-order sensitivities with one forward pass and one reverse sweep.

For the Asian-call benchmark below, the measured adjoint run takes 1.1259 s. Eleven times the measured price-only replay cost gives a bump estimate of 10.8633 s: a 9.6× difference. That is a workload-specific timing result, not a guaranteed speedup for every product or risk engine.

If you are checking correctness first, compare adjoint and bumped Greeks in QuantLab. The side-by-side lesson explains the same replay-count calculation with an executable example.

The other way to get a slope

Adjoint algorithmic differentiation doesn't reprice at all. It records the valuation once as a tape of primitive operations, then walks that tape backwards a single time. One reverse sweep reads off the sensitivity to every input simultaneously — not because it's cheating the math, but because the chain rule composes the same way whichever direction you walk it, and walking backwards from one output is cheap regardless of how many inputs there are. The cost is a small constant on top of the price, not a multiple of it.

A payoff expression drawn as a graph twice. Left, 'forward pass': five nodes — inputs S0, sigma, r feeding S(T), then p = max(S(T) - K, 0), then V — each computed and holding a value, arrows pointing up. Right, 'reverse pass': the same graph with one set of arrows pointing down from V, each node now holding the partial derivative of V with respect to itself; the three input nodes are filled in simultaneously and tagged delta, vega, rho. Caption: the reverse pass touches each node once, so its cost is flat in the number of inputs while bump-and-revalue's grows as 1 + 2N.

The forward pass records each node once. The reverse pass then walks the same edges once, in the opposite direction, and every input’s sensitivity falls out of that single traversal — adding a fourth or fortieth input doesn’t lengthen the walk.

In NablaTensor that's the difference between two method calls:

MonteCarlo<EquityMarket> adjoint = MonteCarlo.of(Products.asianCall())
    .market(market).steps(steps).gradients().on(engine).build();       // value + all Greeks

MonteCarlo<EquityMarket> priceOnly = MonteCarlo.of(Products.asianCall())
    .market(market).steps(steps).valueOnly().on(engine).build();    // value alone

.gradients() compiles a kernel that does the forward pass and the reverse sweep. .valueOnly() compiles a kernel that only does the forward pass. Everything else about the recording — the payoff, the model, the number of scenarios — is identical. The only thing that changes is whether you throw away the tape or walk it back.

Reading the numbers back out is where the asymmetry shows. The adjoint kernel runs once, and every sensitivity is already sitting on the result — one accessor per input, each naming a node the reverse sweep already walked to:

var v = adjoint.run(scenarios, seed);

double price = v.value();
double delta = v.gradient(EquityMarket::spot);   // dV/dS0
double vega  = v.gradient(EquityMarket::vol);    // dV/dsigma
double rho   = v.gradient(EquityMarket::rate);   // dV/dr
// ...add EquityMarket::strike, ::maturity — still the same one run.

Bump has no such object to read from. Each Greek is a fresh pair of runs at a shifted market, and the "accessor" is a finite difference:

double h = 1e-4 * market.spot();
double delta =
    (priceOnly.run(market.withSpot(market.spot() + h), scenarios, seed).value()
   - priceOnly.run(market.withSpot(market.spot() - h), scenarios, seed).value()) / (2 * h);
// vega repeats it around market.withVol(...), rho around market.withRate(...), and so on.

Nobody hand-writes the bump loop either — MonteCarlo.of(...).valueOnly() run 1 + 2N times at shifted markets is the bump-and-revalue implementation here. Same builder, same engine, same seed. The comparison below isn't adjoint-tuned-code versus a naive competitor; it's the same code path measured two ways.

The number, reproduced live

We didn't want to just repeat the number from the repository's own docs, so we reran the benchmark on the machine this article was written on, right before publishing it:

$ mvn -o -q install
$ mvn -o -q -pl nablatensor-bench exec:java \
    -Dexec.mainClass=com.nablatensor.bench.Benchmarks -Dscenarios=2000000 -Dsteps=252

# NablaTensor benchmark

- machine   : JDK 25.0.1+8-LTS, Linux amd64, 16 processors
- product   : Asian call, 252 fixings, fp64
- scenarios : 2,000,000   seed : 42

## adjoint vs bump-and-revalue  (engine cpu-jit)

| method | replays | wall clock | speedup |
|---|--:|--:|--:|
| adjoint (value + 5 Greeks, one sweep) | 1 | 1.1259 s | 9.6x |
| central bump (1 + 2x5 price-only) | 11 | 10.8633 s | 1.0x |

adjoint: price=5.301676 delta=0.561932 vega=22.389375 rho=23.603735

The adjoint row returns a price plus five Greeks: delta, dV/dK, vega, rho and dV/dT. The bump row estimates the cost of obtaining that same set by central differences. Benchmarks.java measures a warmed price-only replay and multiplies by 1 + 2 × 5; it does not time eleven separately shifted markets or verify Greek agreement. ProductBench uses the same timing methodology. Use the bump reconciliation calculator for an independent numerical comparison.

How many repricings does a risk ladder need?

First-order GreeksBase plus central-difference repricingsAdjoint evaluation
13One forward pass + one reverse sweep
511One forward pass + one reverse sweep
2041One forward pass + one reverse sweep
3061One forward pass + one reverse sweep

The count assumes a scalar valuation with all relevant inputs already on the tape. Adding new model structure can enlarge that tape; requesting an existing input's gradient does not add another full revaluation. Higher-order Greeks and discontinuities need additional treatment.

How to choose a bump size

Use the same random seed in the up and down runs, and keep every unrelated market input fixed. Compare several bump sizes in the factor's own units: too large a bump changes the slope you are estimating; too small a bump can magnify numerical noise. Check absolute errors as well as relative errors, especially when the reference Greek is near zero.

It's not just the Asian call

A single flattering product would be a weak argument, so the repository ships a second harness that runs the same comparison across eight different payoffs — vanilla, path-dependent, barrier, and the kind of exotic structures that show up on real books. We reran that one too:

$ mvn -o -q -pl nablatensor-bench exec:java \
    -Dexec.mainClass=com.nablatensor.bench.ProductBench -Dscenarios=1000000 -Dsteps=128

# Per-product: adjoint vs bump  (engine cpu-jit, 1,000,000 scenarios, 128 steps, seed 42)

| product | price | delta | adjoint | bump (1+2x5) | speedup |
|---|--:|--:|--:|--:|--:|
| European call | 9.41940 | 0.59945 | 0.2853 s | 2.7477 s | 9.6x |
| Asian call | 5.31633 | 0.56212 | 0.2856 s | 2.6971 s | 9.4x |
| Lookback call | 17.12529 | 1.14170 | 0.3182 s | 2.7610 s | 8.7x |
| Floating lookback | 15.46483 | 0.15465 | 0.3156 s | 2.8239 s | 8.9x |
| Barrier up-and-out | 3.22952 | 0.05093 | 0.4876 s | 3.9205 s | 8.0x |
| Digital cash-or-nothing | 0.50541 | 0.01926 | 0.2773 s | 2.6671 s | 9.6x |
| Cliquet | 8.97174 | -0.00000 | 0.4198 s | 3.2269 s | 7.7x |
| Autocallable | 97.86984 | 0.05746 | 0.3047 s | 2.7472 s | 9.0x |

Eight products, eight speedups clustered around 8–10x. None of them were picked to make the number look good — that's the whole table the harness prints. The barrier, digital, cliquet and autocallable rows matter for a different reason too: those payoffs have a genuine kink or jump in them, and a raw discontinuous payoff has an adjoint delta that's either undefined or zero almost everywhere. NablaTensor smooths the indicator function under the hood for exactly these products so the adjoint Greek is usable at all — without that, a barrier's "delta" from AD alone would be noise, and bump would win by default. The smoothing width affects the risk estimate and needs validation; the timing table alone does not establish Greek accuracy.

The same story on a different backend

Everything above ran on cpu-jit — a generated bytecode kernel, still plain JVM. Swap one flag and the identical recording replays on simd, the JDK Vector API backend, no re-record and no code change:

$ mvn -o -q -pl nablatensor-bench exec:java \
    -Dexec.mainClass=com.nablatensor.bench.ProductBench \
    -Dengine=simd -Dscenarios=1000000 -Dsteps=128

# Per-product: adjoint vs bump  (engine simd, 1,000,000 scenarios, 128 steps, seed 42)

| product | price | delta | adjoint | bump (1+2x5) | speedup |
|---|--:|--:|--:|--:|--:|
| European call | 9.41940 | 0.59945 | 0.1281 s | 0.9619 s | 7.5x |
| Asian call | 5.31633 | 0.56212 | 0.1237 s | 0.9678 s | 7.8x |
| Lookback call | 17.12529 | 1.14170 | 0.1356 s | 1.0047 s | 7.4x |
| Floating lookback | 15.46483 | 0.15465 | 0.1232 s | 0.9719 s | 7.9x |
| Barrier up-and-out | 3.22952 | 0.05093 | 0.2554 s | 1.6117 s | 6.3x |
| Digital cash-or-nothing | 0.50541 | 0.01926 | 0.1171 s | 0.9554 s | 8.2x |
| Cliquet | 8.97174 | -0.00000 | 0.1982 s | 1.3510 s | 6.8x |
| Autocallable | 97.86984 | 0.05746 | 0.1262 s | 1.0220 s | 8.1x |

Two things worth noticing. First, every price and delta is identical to the cpu-jit table above, to the digit — that's the path-for-path reproduction the project holds itself to across backends, not a coincidence. Second, the speedup shrinks a little, to roughly 6–8x instead of 8–10x — because SIMD makes the underlying per-scenario cost cheaper, the reverse sweep's fixed overhead is a slightly larger fraction of a smaller number. Adjoint still wins every single row. A faster backend doesn't change the argument; it just moves both timings down together.

Why the gap only gets worse

The ratio here is (1 + 2N) / (1 + adjoint overhead), and only one of those terms grows with the size of your risk ladder. At N = 5 Greeks it's roughly 11 / 1.15. Ask for a full FRTB-style sensitivity set — twenty, thirty risk factors — and bump-and-revalue is now 41 or 61 full Monte-Carlo runs against the same single reverse sweep. The adjoint side barely moves; the tape doesn't get longer just because you asked for more outputs from it.

That's the actual argument for adjoint AD in a Monte-Carlo pricing library. Not "it's more elegant" — it's that the bump loop's cost is a bill that scales with how much risk you want to see, and the reverse sweep's bill doesn't.

Pick a product and a backend, then drag the risk-ladder size to see it for yourself. Every bar comes from one measured fp32 run per backend — cpu-jit, SIMD and Vulkan on the GPU, all at the same scenario count — with N=5 measured directly and every other N extrapolating only the replay count, 1 + 2N:

10.1x faster · measured
One measured fp32 run per backend, 500k scenarios / 128 steps. Per-replay cost and reverse-sweep overhead are fixed per product/backend; only the replay count, 1 + 2N, grows with N. On Vulkan the reverse-sweep overhead for branch-heavy payoffs (barrier, cliquet, autocallable) is big enough that adjoint stops winning — GPU divergence on conditional payoff logic costs more than one extra sweep saves.

Bump still wins in two honest cases: a tiny tape with only one or two Greeks (the adjoint overhead isn't amortised over enough outputs to pay for itself), and a genuinely non-differentiable payoff with no smoothing available at all. Neither of those describes a real risk ladder, which is the whole point.

Where the same compute question appears

Try it with current Quant

For a local Java 25 application, use com.nablatensor:quant:0.4.0. Its scalar types come from com.jax4j.engine; keep the jax4j version selected by Quant's POM. Standalone jax4j 0.2.1 uses com.nablatensor.jax4j instead.

The API snippets above use gradients(), gradient(...) and value(); greeks(), greek(...) and price() on replay results are the old names. The printed benchmark transcripts retain their original commands and results. For a current price-and-Greeks example:

git clone --branch v0.4.0 --depth 1 https://github.com/nablatensor-dev/quant.git
cd quant
mvn -q -DskipTests install
mvn -q -pl quant-examples exec:java \
  -Dexec.mainClass=com.nablatensor.examples.VanillaEuropeanGreeks

cpu-jit ships in core. To use SIMD, add the matching optional jax4j-simd artifact and launch Java with --add-modules jdk.incubator.vector.


Questions or corrections? open an issue