← blog19 min read

finmath-lib, Strata, JQuantLib vs. NablaTensor

The two Monte-Carlo tapes and the spot ladder from the last two articles, run on the three Java quant libraries closest to NablaTensor — each as it ships, with every speedup it offers: finmath's backward AAD and memoised Brownian increments, Strata's closed forms, JQuantLib's analytic engine. finmath ties the compiled tape per core on the one-step European and loses 18x on the 252-step Asian, 6x of it threads and 3x of it the tape. Two of the three cannot run the Asian at all.

benchmarksfinmathstrataquantlibmonte-carloautodiffjvmgreeks
Infographic titled 'The same ladder in three other Java libraries'. Two log-scale bar panels of million paths per second on the value-plus-Greeks pass of a warm spot ladder. Left, 252-step Asian: finmath-lib 6.1.9 one thread AAD with fresh draws 0.089, with memoised draws 0.174; cpu-jit one thread 0.22, with cache 0.51; cpu-jit eight threads 1.44, with cache 3.07; simd eight threads 3.12, with cache 3.97. Right, one-step European at 20 million paths: finmath fresh 17.2, memoised 23.7; cpu-jit one thread 15.1, with cache 23.6; cpu-jit eight threads 98.9, with cache 150.1; simd 250.7, with cache 340.9. A grey strip, 'not on the chart because they cannot run it': Strata 2.12 is closed-form Black-Scholes only, 4.1 million valuations per second with five Greeks on one thread, no Monte-Carlo engine, no Asian; JQuantLib 0.3, closed form 4.1 million per second, its MCEuropeanEngine constructor throws UnsupportedOperationException('work in progress'). A banner: on one core and one draw per path, finmath is as fast as the compiled tape — it has AAD, it memoises its draws, and it is honest work; what it lacks is threads and a way to run 252 steps without materialising 1,536 arrays of 200,000 doubles: 7.7 GB of RSS and 0.17 Mpath/s against 1.3 GB and 3.07 for the tape kernel on eight threads, 18x, of which 6x is the thread count and 3x is the tape.

The last two articles measured one thing against itself. The compiled tape against the tape interpreter; the draw cache against no draw cache. Useful, and also the easiest kind of benchmark to win, because the author controls both sides.

So this one puts the same two tapes and the same seven-point spot ladder in front of the three Java libraries a quant would reach for instead of NablaTensor, and runs each of them the way its own documentation says to.

  • finmath-lib 6.1.9 — Christian Fries' library, the standard Java mathematical-finance stack for Monte Carlo, LIBOR market models and xVA. It has backward-mode automatic differentiation built in — a separate extension module until it was folded into the core library at version 3.3 — so it gets to use it.
  • Strata 2.12.74 — OpenGamma's Apache-2.0 market-risk library, used in production at banks for curves, pricing and sensitivities.
  • JQuantLib — the Java port of QuantLib, in the 0.3.0 repackaging that is on Maven Central.

The rules, stated before the numbers so that they cannot be adjusted after:

  1. Every library runs as it ships. If it offers adjoint Greeks, it uses them. If it memoises random draws, that is used too. If it exposes a thread count, it gets eight threads like the NablaTensor engines. If it does not, it runs on one, and the table says so — I did not wrap anything in an executor to manufacture an eight-thread number. What a library can do is the measurement; what I could build on top of it is not.
  2. Same product, same market, same schedule: S = K = 100, σ = 20%, r = 3%, T = 1, the one-step European call and the 252-fixing arithmetic Asian call, log-Euler on a uniform grid.
  3. Same ladder: spots 98, 99, 99.5, 100, 100.5, 101, 102, one seed, warm-up on a different seed, three ladders, median of the six warm calls. The NablaTensor rows are the ones already published in the draw-cache article, plus a one-thread cpu-jit row measured for this piece so that there is a per-core comparison in both directions.

Everything below was measured on referential machine — Ryzen 7 8845HS, Zulu OpenJDK 25.0.1 — with the three libraries pulled from Maven Central at the versions above.

What each library turned out to be able to do

This section was supposed to be one paragraph. It is the most important section in the article.

finmath-lib can do all of it. BlackScholesModel plus EulerSchemeFromProcessModel plus a payoff written against its RandomVariable type is the Asian in nine lines. Wrap the model's inputs in RandomVariableDifferentiableAADFactory and the same nine lines produce delta, strike sensitivity, vega and rho from one getGradient() call — a real backward sweep over an operator graph, the same idea as NablaTensor's tape. And BrownianMotionFromMersenneRandomNumbers generates its increments lazily and keeps them, so two models built on the same BrownianMotion object share draws: finmath has had a draw cache since before NablaTensor existed, and it is on by default.

What finmath does not have, on this workload, is a second core. EulerSchemeFromProcessModel does have multithreading — a system property, isUseMultiThreadding, on by default — but it parallelises across model components, and a Black-Scholes model has one. The arithmetic on a RandomVariableFromDoubleArray is a plain sequential loop. Measured with the serial collector so that GC threads could not confuse the reading: 8.25 s of user time in 9.91 s of wall clock. One thread, as shipped. (An eight-way path shard is about forty lines of your own code and works fine; I wrote it, it is not in the tables, because it is my code and not the library's.)

Strata has no Monte-Carlo engine. None. It is a market-risk library built on closed forms, curves and calibration, and its equity-option content is BlackScholesFormulaRepository: price, delta, dual delta, gamma, vega, rho, theta and a dozen higher-order Greeks, each a static method on six doubles and a call/put flag. For the European that is the right tool and it appears below. For the Asian there is nothing to call.

JQuantLib has an MCEuropeanEngine class, and I was looking forward to it. Its constructor, in the 0.3.0 jar:

public MCEuropeanEngine();
  Code:
     4: new           #2   // class java/lang/UnsupportedOperationException
     8: ldc           #3   // String work in progress
    13: athrow

MCVanillaEngine has an empty constructor and nothing else; the Asian engines present are the two closed-form geometric-average ones. The Monte-Carlo half of the QuantLib port was never finished. AnalyticEuropeanEngine does work, and returns QuantLib's full Greek set from one BlackCalculator pass, so JQuantLib also appears in the European table — as a closed form.

So the honest shape of the comparison is: on the European, five contenders, two of them not simulating anything; on the Asian, the one that actually needs Monte Carlo, one library besides NablaTensor can run it at all.

The same thing as a figure, with the two questions a reader asks before any benchmark — can I <dependency> it, and what hardware does it use — added:

Capability grid titled 'What each library can do, before any number'. Rows finmath-lib 6.1.9, Strata 2.12.74, JQuantLib 0.3.0, NablaTensor 0.1.0; columns Maven Central, Monte Carlo, adjoint Greeks, draw cache, threads, SIMD, GPU. All four are one Maven dependency: net.finmath:finmath-lib, com.opengamma.strata:strata-pricer, co.dv01.jquantlib:core, com.nablatensor:nablatensor-quant plus nablatensor-simd. finmath: Monte Carlo yes, backward AAD yes, draw cache on by default, threads only per model component so one here, no SIMD, GPU via separate cuda and opencl extension artefacts not used here. Strata: no Monte Carlo, analytic closed-form sensitivities, no draw cache, multithreaded CalculationRunner, no SIMD, no GPU. JQuantLib: Monte Carlo unfinished, closed-form Greeks only, single-threaded, no SIMD, no GPU. NablaTensor: recorded tape replayed by a compiled kernel, adjoint sweep, opt-in DRAW_CACHE on cpu-jit and simd, threads(n), simd engine on the JDK Vector API, vulkan cuda rocm opencl engines none of which appear in this article.
Green is as shipped, orange is partial or a separate artefact, red is not there. Every one of the four is a single Maven Central dependency — checked on repo1.maven.org, not assumed.
Did you know?

finmath's random numbers have a period of 2¹⁹⁹³⁷ − 1, and that is why they are called what they are called. BrownianMotionFromMersenneRandomNumbers draws from the Mersenne Twister, MT19937, published by Makoto Matsumoto and Takuji Nishimura in 1998. Its period is 2¹⁹⁹³⁷ − 1, which is a Mersenne prime — a prime of the form 2ᵖ − 1 — and the "twister" is the linear transformation on its 624-word state that makes the recurrence cheap. It is the default generator in Python, R, Ruby, PHP, MATLAB, Julia until 1.7, and most of the simulation code written this century.

It is also exactly the kind of generator that a counter-based design like Philox exists to replace: it has 2.5 KB of state and a place it is up to, so path p can only be generated after paths 0 through p − 1, on the thread that generated them. That is why finmath's Brownian increments have to be produced once, sequentially, and stored — which is the same reason its draw cache is on by default. A stateless generator can afford to regenerate; a stateful one cannot afford not to remember.

The one-step European

Twenty million paths, fp64. finmath is measured once per configuration on one thread; the NablaTensor rows are from the draw-cache article, plus the one-thread cpu-jit rows measured for this one.

librarythreadsGreeksdrawsprice onlyvalue + Greekspeak RSS, price / Greeks
finmath-lib 6.1.91AAD, 4fresh BrownianMotion per call33.7 Mpath/s17.211.1 / 16.8 GB
finmath-lib 6.1.91AAD, 4one BrownianMotion, memoised68.323.710.1 / 16.5 GB
cpu-jit1adjoint, 5regenerated23.815.167 / 70 MB
cpu-jit + DRAW_CACHE1adjoint, 5cached40.523.6351 / 281 MB
cpu-jit8adjoint, 5regenerated155.698.968 / 72 MB
cpu-jit + DRAW_CACHE8adjoint, 5cached285.4150.1369 / 375 MB
simd8adjoint, 5regenerated340.9250.7127 / 553 MB
simd + DRAW_CACHE8adjoint, 5cached632.1340.9428 MB / 1.2 GB

The last column is the peak resident set of the whole process — every thread, the JVM, and the draw cache — for the price-only run and for the Greeks run, read with GNU time from a separate one-ladder run of each configuration. The NablaTensor rows ran with the default heap; the finmath rows with the -Xmx16g the Asian below needs, and finmath fills whatever heap it is given: every RandomVariable operation allocates a fresh 160 MB double[20,000,000], and G1 lets them pile up until the ceiling. The same European ladder completes in a 1 GB heap on the price pass and a 4 GB heap on the Greeks pass, so read the finmath cells as "what it takes when it can", not as a floor. (A spot check worth its own article: with -Xmx1g the price-only pass ran 2.8× faster than with -Xmx16g on this box, because the collector reuses the same few regions instead of touching fresh pages for every array. The 68.3 above was measured with a 14 GB heap.)

And the two closed forms, which do not simulate and are listed in their own unit because a "path" is not a thing they have:

librarythreadswhat a valuation isvaluations / speak RSS
Strata 2.12.74 BlackScholesFormulaRepository1price + delta, dual delta, vega, rho, theta (six static calls)4.10 M60 MB
JQuantLib 0.3.0 AnalyticEuropeanEngine1NPV() + delta, vega, rho, theta from one BlackCalculator4.08 M527 MB

Three things to take from this, and the first is the one I did not expect.

On one core, finmath is as fast as the compiled tape, and on the price pass it is faster. 33.7 against 23.8 million paths per second with fresh draws; 68.3 against 40.5 with cached ones. On the Greeks pass, memoised draws on both sides, it is a dead heat: 23.7 against 23.6. NablaTensor's whole first article was about the cost of interpreting a tape per path, and here is a library that does not compile anything, does not generate bytecode, and matches it.

The reason is that finmath does not interpret per path either. Its RandomVariable is an array of paths, and every operation — mult, exp, sub, floor — is one tight loop over twenty million doubles. That is the NumPy design: dispatch once per operation, not once per operation per path, so the dispatch cost that cpu-jit had to compile away is amortised over the whole array instead. On a one-step tape with a dozen operations, each of them a streaming pass over 160 MB, it is a perfectly good way to run a Monte Carlo. The draw generation is also cheaper: Mersenne Twister plus an inverse normal CDF, against Philox's ten rounds and Box-Muller's log, sqrt and sincos.

The memoised draws are worth the same thing in both libraries. finmath's price pass goes 2.0× faster on cached increments, its Greeks pass 1.4×; the draw-cache article measured 1.8× and 1.5× for cpu-jit. That is not a coincidence, it is Amdahl on the same RNG share, and it is a quiet confirmation that the two libraries are doing comparable amounts of work per path once the random numbers are out of the way.

The 6.3× between them at the end of the table is the thread count and nothing else. cpu-jit on eight threads with the cache does 150.1 on the Greeks pass; finmath, at parity per core, does 23.7. Every bit of that gap is cores finmath cannot use, and every bit of simd's further 2.3× is the Vector API. On this tape the compiled kernel wins by being parallel, not by being compiled.

And the closed forms: 4.1 million complete valuations per second, price and five Greeks, on one thread. That is 240 nanoseconds each. Twenty million Monte-Carlo paths at 632 million per second take 32 milliseconds to reach a price with a standard error of 0.003; Strata reaches the exact answer in a quarter of a microsecond. Nobody prices a vanilla European by simulation, and the row is here to say so in numbers. The Monte-Carlo rows exist because of the next table.

The 252-step Asian

The tape that needs simulation: 1,536 nodes, 252 draws per path, an arithmetic average that no closed form covers. Strata has nothing to call. JQuantLib's engine throws. finmath runs it — at 200,000 paths rather than the 300,000 of the NablaTensor rows, because its AAD operator graph keeps a double[paths] per node and 300,000 paths did not fit in a 16 GB heap. Throughput is per path and does not depend on the count at this size, but the difference is stated.

librarythreadsGreeksdrawsprice onlyvalue + Greekspeak RSS, price / Greeks
finmath-lib 6.1.91AAD, 4fresh BrownianMotion per call0.125 Mpath/s0.0894.7 / 7.2 GB
finmath-lib 6.1.91AAD, 4one BrownianMotion, memoised0.3380.1744.8 / 7.7 GB
cpu-jit1adjoint, 5regenerated0.260.2276 / 112 MB
cpu-jit + DRAW_CACHE1adjoint, 5cached0.840.51995 / 829 MB
cpu-jit8adjoint, 5regenerated1.691.4480 / 119 MB
cpu-jit + DRAW_CACHE8adjoint, 5cached4.623.071.2 / 1.3 GB
simd8adjoint, 5regenerated4.293.12191 / 419 MB
simd + DRAW_CACHE8adjoint, 5cached7.693.971.4 / 1.4 GB
Strata 2.12.74no Monte-Carlo engine
JQuantLib 0.3.0MCEuropeanEngine: "work in progress"

Same memory column as above, whole process, price run / Greeks run. The NablaTensor rows are at 300,000 paths, so their draw cache is 300,000 × 252 doubles, 605 MB, and that is what separates the cached rows from the uncached ones. The finmath Greeks pass at 200,000 paths is 7.7 GB and does not fit at 300,000 paths with a 16 GB heap; that is not the heap filling up for lack of collection, it is the operator graph, as the next section says.

Read the two bold columns. On the Greeks pass, best configuration against best configuration, cpu-jit on eight threads is 17.6× finmath. But the one-thread cpu-jit row is there so that the ratio can be taken apart: cpu-jit on one core with the cache does 0.51 against finmath's 0.174 — a 2.9× per-core gap — and eight threads then multiply it by 6.0. On the European the per-core gap was 1.0. Something about the long tape costs finmath a factor of three per core that the short tape did not, and the memory column says what.

Two-by-two grid titled 'The same Asian, adjoint Greeks, in both libraries'. Top left, finmath-lib 6.1.9 code: an AAD factory wrapping an array factory, four differentiable leaves for spot, strike, vol and rate, a 252-step time discretisation, a memoised BrownianMotion, a BlackScholesModel and an Euler scheme, a payoff summing the 252 asset values, dividing by 252, subtracting the strike, flooring at zero and discounting, then getGradient() and getAverage(). Top right, NablaTensor code: a payoff lambda over ADouble that steps a GBM path 252 times with one draw per step, sums, averages, subtracts the strike, takes the max with zero and discounts; then Nabla.model(...).greeks().fp64().threads(8).on('cpu-jit').jit(DRAW_CACHE).build(), and a run with the shocked market as an argument returning price and Greeks. Bottom left, 'what runs — finmath': a row of eighteen tall bars standing for about 1,500 vector operations applied once each in order on one thread, each bar a double array of 200,000 elements, 1.6 MB, written to the heap and kept for getGradient(); peak RSS 7.7 GB, 0.174 million paths per second. Bottom right, 'what runs — cpu-jit': a draw-cache block of 300k by 252 doubles feeding eight lanes into one compiled kernel box, forward and reverse, 1,536 nodes of generated bytecode with 12 KB of state per path in L1, summing value and five Greeks per thread; peak RSS 119 MB without the cache, 1.3 GB with it, 3.07 million paths per second. Footer: same Wengert list, same reverse sweep, same 1.7 to 1.9 times adjoint cost; one tape is vector ops applied once, the other scalar ops applied per path.

The code that produced the two Asian rows, and what each of them makes the machine do. Read the two bottom panels against the RSS column: the difference is not the maths, it is where the intermediate values live.

Where the three-per-core goes

The array-of-paths design has a cost that the one-step tape hid and the 252-step tape exposes: every intermediate is materialised. A RandomVariable operation reads one or two arrays of 200,000 doubles from memory and writes a new one. The Asian is 252 Euler steps of drift, diffusion, exp, multiply and running sum, then the average and the payoff — on the order of 1,500 vector operations, each a 1.6 MB write. The EulerSchemeFromProcessModel keeps every step's state (it has to, the payoff reads all 252 fixings), and the AAD factory keeps every operand of every operation, because the backward sweep will need them. That is the 7.7 GB in the last column: not a leak, the operator graph doing exactly what an operator graph over arrays has to do.

The compiled tape holds the same 1,536 values per path — 12 KB, in L1 — and throws them away when the path is done. It touches main memory for the draws and for nothing else. That is the difference between 119 MB and 7.7 GB, and it is also, once the working set is bigger than any cache on the chip, the difference between 0.51 and 0.174 million paths per second on a single core. Same Wengert list, same reverse sweep, same Baur–Strassen bound on the adjoint cost — finmath's Greeks pass is 1.9× its price pass with memoised draws, cpu-jit's is 1.66× — but one of them is transposed, and the transposed one streams its tape through DRAM.

The same tape, two layouts. NablaTensor's tape is a list of scalar operations, replayed once per path; the parallelism is across paths and lives in the thread count and the Vector API. finmath's tape is a list of vector operations, applied once, each over every path; the parallelism is inside each operation, and the tape itself runs sequentially. For a short tape with wide arrays the second layout wins on dispatch cost and ties per core. For a long tape it pays for every intermediate in memory bandwidth and heap, and cannot spread the operations across cores because they depend on each other in order. Neither is wrong. They are the two ways to vectorise a Monte Carlo, and the workload decides.

The draws, again

finmath's memoised BrownianMotion lifts its Asian price pass 2.7× and its Greeks pass 2.0×. The draw-cache article measured 2.7× and 2.1× for cpu-jit. Two libraries with different generators — Mersenne Twister with an inverse CDF against Philox with Box-Muller — different array layouts and no shared code arrive at the same lever with the same leverage, because on a 252-draw tape the random numbers are around two thirds of a price path whichever way you compute the rest. If there is one number in this article that generalises past the two libraries measured, it is that one.

Did they agree?

Different generators mean different noise, so nothing here is bit-comparable across libraries; what should agree is the estimate within its own standard error, and the Greeks within theirs.

pricedeltadV/dKvegarhodV/dT
European, closed form (Strata, JQuantLib)9.41340.59871−0.5045738.66750.4575.380
European, finmath, 20 M paths, AAD9.41880.59883−0.5046438.69750.464
European, cpu-jit, 20 M paths, adjoint9.41220.59870−0.5045838.66150.4585.380
Asian, finmath, 200 k paths, AAD5.31400.56210−0.5089622.46523.603
Asian, cpu-jit, 300 k paths, adjoint5.30100.56163−0.5086222.38623.5912.946

(dV/dT is the derivative with respect to maturity; Strata and QuantLib report its negative and call it theta, −5.380.)

The European standard error at twenty million paths is 0.0032 as reported by the engine; finmath sits 1.7 of them above the closed form and cpu-jit 0.4 below, which is what two independent estimates of the same number look like. The Asian's standard error is 0.014 at 300,000 paths, and the two estimates are 0.013 apart. The Greeks agree to three figures everywhere they overlap; the one that does not overlap is dV/dT, because finmath's time grid is not something its AAD can differentiate through, while for the tape maturity is just another input node.

What to actually use

The ranking of the things that decided this comparison, in the order they decided it:

  1. Whether the library can run the product at all. Two of three could not run the Asian. No amount of per-core efficiency matters to a row that says "work in progress".
  2. Threads. 6× of the 17.6× on the Asian, all of the 6.3× on the European. finmath is a single-threaded library on a single-component model, as shipped; you can shard it yourself, and then the number you get is a measure of your sharding.
  3. Per-path versus per-array. A wash on a twenty-six-node tape; 2.9× per core and 7.7 GB against 119 MB on a 1,536-node one. This is the only line in the list that is about NablaTensor's design rather than its configuration, and it only shows up on the tape that needed simulation in the first place.
  4. The draw cache. 2–2.7× on the price pass in both libraries, and finmath's is on by default. If you take one engineering idea from this series to a codebase that is not NablaTensor, take that one.
  5. Adjoint Greeks. Not a differentiator between finmath and NablaTensor, because both have them and both pay about 1.7–1.9× a price pass for every Greek at once. A differentiator against everything that bumps.

If your book is closed-form products and curves, Strata is the production library and this article has nothing to tell you; 4.1 million valuations a second with the Greeks attached is the correct way to price a European. If your book is path-dependent and single-threaded is acceptable — or you are content to shard it yourself — finmath is a serious, honest, well-designed library that has had adjoint Greeks and cached draws for years, and it will tie the compiled tape per core on anything short. If your book is path-dependent, your tapes are long and you have cores, the tape kernel is 18× on this box before anyone touches a GPU.

Try it

The harnesses are plain main methods against Maven Central artefacts — no build of any of the four libraries is required:

<dependency><groupId>net.finmath</groupId><artifactId>finmath-lib</artifactId><version>6.1.9</version></dependency>
<dependency><groupId>com.opengamma.strata</groupId><artifactId>strata-pricer</artifactId><version>2.12.74</version></dependency>
<dependency><groupId>co.dv01.jquantlib</groupId><artifactId>core</artifactId><version>0.3.0</version></dependency>
<dependency><groupId>com.nablatensor</groupId><artifactId>nablatensor-quant</artifactId><version>0.1.0</version></dependency>
<dependency><groupId>com.nablatensor</groupId><artifactId>nablatensor-simd</artifactId><version>0.1.0</version></dependency>

nablatensor-quant pulls in cpu-jit; simd is the second artefact and needs --add-modules jdk.incubator.vector.

finmath's Asian with adjoint Greeks, in full — this is the code that produced its rows, minus the timing loop:

var f   = new RandomVariableDifferentiableAADFactory(new RandomVariableFromArrayFactory(true));
var rvS = f.createRandomVariable(spot);  var rvK = f.createRandomVariable(100.0);
var rvV = f.createRandomVariable(0.20);  var rvR = f.createRandomVariable(0.03);
var td  = new TimeDiscretizationFromArray(0.0, 252, 1.0 / 252);
var bm  = new BrownianMotionFromMersenneRandomNumbers(td, 1, paths, seed, new RandomVariableFromArrayFactory(true));
var model = new BlackScholesModel(rvS, rvR, rvV, f);
var sim   = new MonteCarloAssetModel(model, new EulerSchemeFromProcessModel(model, bm));

RandomVariable sum = sim.getAssetValue(td.getTime(1), 0);
for (int i = 2; i <= 252; i++) sum = sum.add(sim.getAssetValue(td.getTime(i), 0));
RandomVariable value = sum.div(252).sub(rvK).floor(0.0)
    .div(sim.getNumeraire(1.0)).mult(sim.getNumeraire(0.0));

Map<Long, RandomVariable> g = ((RandomVariableDifferentiable) value).getGradient();
double price = value.getAverage(), delta = g.get(rvS.getID()).getAverage();   // etc.

Reuse bm across the seven spots and you have finmath's draw cache; build a new one per spot and you have the "fresh" row. Set -Xmx16g for 200,000 paths and watch the resident set with /usr/bin/time -v, because the number in the last column of the Asian table is the one that will decide whether your ladder fits on your box before the throughput does.

Source: bench/three-java-quant-libraries-one-ladder (the three harnesses and their pom.xml, on the main engine repo), JitReplay.java (the NablaTensor side), and for finmath RandomVariableDifferentiableAADFactory, BrownianMotionFromMersenneRandomNumbers and EulerSchemeFromProcessModel (the memoised increments and the per-component executor).


Questions or corrections? open an issue