Five Non-Java Quant Libraries, One NablaTensor Baseline
The two Monte-Carlo tapes and the spot ladder from the last three articles, run on the five most-used quant libraries that are not Java — QuantLib (plus its XAD adjoint build), tf-quant-finance, financepy, RustQuant and pfhedge — each as it ships. One returns NaN for the European by simulation, one has no seed, one prices a different distribution, and the fastest of the five — TensorFlow, on eight threads — is still 12x behind the simd engine on both tapes.
The last article put the two Monte-Carlo tapes and the seven-point spot ladder in front of the three Java libraries a quant would reach for instead of NablaTensor. The obvious objection is that nobody outside a bank's Java estate reaches for a Java library. The quant desk runs Python on top of C++; the research group runs TensorFlow or PyTorch; the person rewriting the risk engine this year is doing it in Rust.
So this one runs the same ladder on the five most-used quant libraries that are not Java, on referential machine, CPU only, each as it ships:
- QuantLib 1.43 — the C++
reference library, twenty-five years old, 7,600 stars, driven here through
its own Python wheel, which is the way most of its users drive it. Plus
QuantLib-Risks
1.33.3, Xcelerit's build of the same library on the XAD automatic
differentiation tool, because QuantLib's answer to "where are the adjoint
Greeks" is that package, and it is
pip install-able. - tf-quant-finance 0.0.1.dev34 — Google's TensorFlow library, 5,500 stars, archived with a note that says to fork it. Last release August 2022, run here on TensorFlow 2.21.
- financepy 1.1.2 — Dominic O'Kane's numba-accelerated Python library, 3,100 stars, released three weeks ago.
- RustQuant 0.3.1 — the Rust library, 1,800 stars, with a Monte-Carlo pricer over rayon and its own reverse-mode autodiff crate.
- pfhedge 0.23.0 — Preferred Networks' PyTorch library for deep hedging, the PyTorch entry, run on torch 2.14.
The rules are the ones from the Java article, restated so that they cannot be adjusted after the numbers:
- As it ships. If it has adjoint Greeks, it uses them. If it can keep
its draws between calls, that is used too. If it exposes threads — a
parallel: true, an intra-op pool, aprange— it gets eight of them like the NablaTensor engines; if it does not, it runs on one and the table says so. No executor wrapped around anything. What a library can do is the measurement; what I could build on top of it is not. - Python is not penalised. The harness clock starts before the call and
stops after it, in whatever language the library is called from. The
interpreter overhead of a QuantLib
NPV()or atf.functiondispatch is part of what the user pays, so it is part of the number. - 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. 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. Greeks means delta,dV/dK, vega, rho anddV/dT, or as many of those as the library can produce. - No seed means no seed. If a library gives me no way to fix its random numbers, its bump-and-revalue Greeks are measured on fresh draws, and the number that comes out is reported as it comes out.
The NablaTensor rows are the ones already published in the draw-cache and Java articles, measured on this same box — Ryzen 7 8845HS, 8 cores, Zulu OpenJDK 25.0.1 — and everything else below was measured on it on 11 September, one process at a time, with the libraries installed from PyPI and crates.io that afternoon.
What each library turned out to be able to do
As in the Java article, this was supposed to be a paragraph and it is the most
important section. Five libraries went in; one of them returns NaN
for the European by simulation as shipped, one has no seed, one prices a
different distribution, one has no interest rate, and one needed an
environment variable to import.
QuantLib can do all of it, on one thread, with bumps. MCEuropeanEngine
and MCDiscreteArithmeticAPEngine are the engines; the process is a
BlackScholesProcess over three SimpleQuotes, and a Greek is
quote.setValue(x + h); option.NPV() — the observer pattern re-runs the
engine on the same seed, so the bumps are common-random-number bumps and the
Greeks are clean. There is no adjoint in QuantLib proper. There is also a trap
I walked into and want on the record: I first built the Asian on a
Business252(NullCalendar()) day counter, because 252 calendar days at 1/252
each is exactly NablaTensor's grid. Business252::yearFraction counts
business days by walking the calendar, the engine calls it per step, and the
European ran 150× slower — 0.043 million paths per second instead of 6.3 —
before I switched to Actual365Fixed and 252 fixings at
round(i·365/252) days. The grid in the tables is that one, a day off
uniform here and there; the throughput is unaffected by which day the fixings
land on.
QuantLib-Risks is QuantLib 1.33 recompiled with Real as an XAD active
type, and it really does give adjoint Greeks through the Monte-Carlo engines:
register spot, strike, vol and rate as tape inputs, call the same NPV(), ask
for the adjoints, and delta, dV/dK, vega and rho come back from one sweep.
dV/dT does not, because the time grid is dates. The cost is the tape: it
records every arithmetic operation of every path — 1.3 KB per European path,
170 KB per 252-step Asian path — so the European row is at 2 million paths
(2.6 GB) and the Asian at 20,000 (3.5 GB), not 20 million and 200,000. The
price-only rows run with the tape deactivated, and even then the XAD build is
about half the speed of plain QuantLib, which is what an active type in every
double costs.
tf-quant-finance is archived, and importing it on a current TensorFlow
fails on its bundled protobufs until PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
is set. After that it works exactly as designed: GeometricBrownianMotion .sample_paths with a stateless seed, a tf.GradientTape over spot, strike,
vol, rate and maturity for all five Greeks, eight intra-op threads, and — this
is the one feature no other library here has — a normal_draws argument that
takes the draw block from the caller. Generate the block once per seed, hand it
back on every call of the ladder, and that is DRAW_CACHE in TensorFlow. It
gets its own rows. (I also tried jit_compile=True: XLA on CPU made the Asian
Greeks pass 45% slower, so it is not in the tables.)
financepy has an Asian Monte-Carlo pricer that works —
EquityAsianOption.value_mc_fast_vc_numba, antithetic with a geometric control
variate, numba, one thread, seeded — and a European one that does not. Every
EquityVanillaOption.value_mc* method in 1.1.2 returns NaN, because the
wrapper passes opt_type in the fourth position and the kernel underneath
declares r there; the master branch on GitHub has the same lines. The kernel
itself is fine when called in the order it declares, so its numbers are in the
text below as what the library would do, marked, and the shipped European
row is the closed form — value() plus analytic delta, vega, rho and theta —
which is the one that works. One more as-shipped detail: the prange kernel
that would give the European eight threads seeds one thread's generator, so it
returns a different price on every call with the same seed and cannot be
bumped.
RustQuant has a MonteCarloPricer for both products over rayon, and no
seed. euler_maruyama draws from rand::thread_rng() for every path and the
StochasticProcessConfig has no field to change that, so the same call gives a
different price every time and a bump is measured against fresh noise. Its
Greeks are in the tables with an asterisk and a section of their own. Two
smaller things: the only scheme is Euler–Maruyama on the level, S + rS·dt + σS·dW, so a one-step European under it is not lognormal — it prices 9.29
against the closed form's 9.41 — and the Asian average runs over the 253 grid
points including S₀. Both are what the library does, so both are what was
measured.
pfhedge is a hedging library. BrownianStock simulates the paths and a
derivative's payoff() reads them, but there is no rate, no discounting and
no Asian: the harness sets mu = r, multiplies by exp(−rT), and defines the
arithmetic Asian as a twelve-line BaseDerivative subclass, which is the
library's documented extension point. Greeks are torch autograd, and they work
through spot, strike, sigma and mu passed as tensors — four of the five;
dt goes through new_tensor(), which detaches it, so no dV/dT. Eight
intra-op threads, torch.manual_seed for the ladder, draws regenerated per
call.
The one-step European
Twenty million paths, fp64, warm ladder, million paths per second per call.
The NablaTensor rows are from the draw-cache article plus the one-thread
cpu-jit rows from the Java one.
| library | threads | Greeks | draws | price only | value + Greeks | peak RSS, price / Greeks |
|---|---|---|---|---|---|---|
| QuantLib 1.43 | 1 | bump ×5, same seed | regenerated | 5.75 | 1.00 | 565 / 565 MB |
| QuantLib-Risks 1.33.3 (XAD), 2 M paths | 1 | adjoint, 4 | regenerated | 3.12 | 0.73 | 121 MB / 2.6 GB |
| financepy 1.1.2 | 1 | — | — | value_mc* returns NaN; closed form below | 318 MB | |
| RustQuant 0.3.1 | 8 | bump ×5, no seed | fresh every call | 33.6 | 5.5 * | 1.1 / 1.1 GB |
| pfhedge 0.23.0 / torch 2.14 | 8 | autograd, 4 | regenerated | 17.1 | 12.5 | 1.5 / 2.2 GB |
| tf-quant-finance / TF 2.21 | 8 | GradientTape, 5 | stateless, regenerated | 57.1 | 25.9 | 1.3 / 2.2 GB |
tf-quant-finance + normal_draws | 8 | GradientTape, 5 | block kept per seed | 66.3 | 27.6 | 1.5 / 2.3 GB |
cpu-jit | 1 | adjoint, 5 | regenerated | 23.8 | 15.1 | 67 / 69 MB |
cpu-jit + DRAW_CACHE | 1 | adjoint, 5 | cached | 40.5 | 23.6 | 356 / 291 MB |
cpu-jit | 8 | adjoint, 5 | regenerated | 155.6 | 98.9 | 68 / 70 MB |
cpu-jit + DRAW_CACHE | 8 | adjoint, 5 | cached | 285.4 | 150.1 | 369 / 373 MB |
simd | 8 | adjoint, 5 | regenerated | 340.9 | 250.7 | 124 / 898 MB |
simd + DRAW_CACHE | 8 | adjoint, 5 | cached | 632.1 | 340.9 | 424 MB / 1.0 GB |
The last column is the peak resident set of the whole process — every thread,
the interpreter or JVM, the TensorFlow or torch runtime, and the cache —
for the price-only run and for the Greeks run, read from VmHWM for the
Python harnesses and from GNU time for the Rust binary and the JVM. The
NablaTensor rows were re-measured for this column against the Maven Central
jars with the default heap; their throughputs are the published ones. Twenty
million doubles is 160 MB, which is what DRAW_CACHE adds to cpu-jit and
what one intermediate tensor costs TensorFlow, a dozen times over.
* RustQuant's Greeks pass is the cost of six valuations; the Greeks it produces are noise, see below.
And the closed form, in its own unit, next to the two Java ones from the last article for scale:
| library | threads | what a valuation is | valuations / s |
|---|---|---|---|
financepy 1.1.2 EquityVanillaOption | 1 | value() + delta, vega, rho, theta (five Python calls) | 6.5 k |
| Strata 2.12.74 (Java, last article) | 1 | price + five Greeks, six static calls | 4.10 M |
| JQuantLib 0.3.0 (Java, last article) | 1 | NPV() + four Greeks, one BlackCalculator | 4.08 M |
Four things, and the first is about TensorFlow.
tf-quant-finance is the fastest of the five quant libraries on this tape,
and it is finmath's design with threads. 57.1 million paths per second on the price
pass, 25.9 with all five Greeks, on eight threads. Its sample_paths is an
array-of-paths operation exactly like finmath's RandomVariable — one
dispatch per op, a streaming pass over 160 MB per op — and TensorFlow's Eigen
pool splits each of those passes across the cores. finmath did 23.7 on the
Greeks pass on one core with memoised draws; TensorFlow on eight does 27.6.
That is not eight cores' worth, and the reason is the same as it will be on
the Asian: every intermediate is a 160 MB tensor written to DRAM and read back,
and eight cores share one memory bus. cpu-jit on eight threads, which keeps
its intermediates in registers, does 150.1 on the same pass, 5.4× ahead;
simd is 12.4× ahead.
The draw block is worth 16% to TensorFlow and 80% to cpu-jit. Handing
sample_paths a pre-generated normal_draws tensor lifts the price pass from
57.1 to 66.3. The draw-cache article measured 1.8× for the same trick in
cpu-jit. The difference is Amdahl: in a kernel that keeps everything in L1,
generating the draws is most of the work, so caching them is most of the win;
in a kernel that streams every operation through memory, the RNG is one
streaming op among a dozen, and caching it removes one twelfth of the traffic.
Same lever, different fulcrum.
The C++ reference is 24× behind the compiled tape per core, before
threads. QuantLib on one thread does 1.00 million paths per second with
five bumped Greeks; cpu-jit on one thread with the cache does 23.6 with five
adjoint ones. Six of that 24 is bumping against adjoint — six valuations
against one-and-two-thirds — and the remaining four is one path object at a
time, virtual drift() and diffusion() per step, through a Path and a
PathPricer, in a library designed in 2000 for correctness and generality
rather than for this. QuantLib-Risks replaces the six valuations with one
tape and lands at 0.73, below the bumped version, because recording 20
operations per path into a tape costs more than the five extra valuations it
saves on a tape this short. It will not be below on the Asian.
pfhedge and RustQuant are the middle of the chart for opposite reasons.
pfhedge is the same array-of-paths design as TensorFlow at a third of the
throughput, mostly because generate_geometric_brownian computes a cumulative
sum and an exponential over an (N, 2) matrix for a one-step product and
because torch's autograd retains more than a GradientTape does. RustQuant is
a per-path design like QuantLib and NablaTensor — a Vec<f64> per path,
rayon over paths — and on eight threads it manages 33.6 on the price pass,
about what cpu-jit does on one, because it heap-allocates two vectors per
path and pays the scalar exp and normal draw per step with no vectorisation.
And then its Greeks are noise, which makes the rest moot.
For completeness, the financepy kernel that its NaN wrapper hides,
value_mc_numba_only, called directly in the argument order it declares: 29.8
million paths per second on the price pass and 5.1 with five same-seed bumps,
on one thread, antithetic. That is the fastest single-threaded Monte Carlo in
this article that is not NablaTensor, 5× QuantLib per core, and it is not in
the table because no user of the library can reach it through the API.
The 252-step Asian
The tape that needs simulation. 200,000 paths for every library that could take them, matching finmath in the Java article; 20,000 for QuantLib-Risks, whose tape does not fit more; the NablaTensor rows at their published 300,000. Throughput per path does not depend on the count at this size, and the last column is memory, because for two of these libraries it is the number that decides whether the ladder runs at all.
| library | threads | Greeks | draws | price only | value + Greeks | peak RSS, price / Greeks |
|---|---|---|---|---|---|---|
| QuantLib 1.43 | 1 | bump ×5, same seed | regenerated | 0.033 | 0.0056 | 57 / 58 MB |
| QuantLib-Risks 1.33.3 (XAD), 20 k paths | 1 | adjoint, 4 | regenerated | 0.017 | 0.0053 | 56 MB / 3.5 GB |
financepy 1.1.2 value_mc_fast_vc_numba | 1 | bump ×5, same seed | regenerated | 0.115 | 0.020 | 332 / 332 MB |
| RustQuant 0.3.1 | 8 | bump ×5, no seed | fresh every call | 1.17 | 0.20 * | 404 / 404 MB |
| pfhedge 0.23.0 / torch 2.14 | 8 | autograd, 4 | regenerated | 0.154 | 0.115 | 1.8 / 2.6 GB |
| tf-quant-finance / TF 2.21 | 8 | GradientTape, 5 | stateless, regenerated | 0.485 | 0.273 | 1.5 / 2.8 GB |
tf-quant-finance + normal_draws | 8 | GradientTape, 5 | block kept per seed | 0.707 | 0.329 | 1.9 / 2.8 GB |
cpu-jit | 1 | adjoint, 5 | regenerated | 0.26 | 0.22 | 75 / 108 MB |
cpu-jit + DRAW_CACHE | 1 | adjoint, 5 | cached | 0.84 | 0.51 | 1.0 GB / 884 MB |
cpu-jit | 8 | adjoint, 5 | regenerated | 1.69 | 1.44 | 82 / 119 MB |
cpu-jit + DRAW_CACHE | 8 | adjoint, 5 | cached | 4.62 | 3.07 | 1.2 / 1.3 GB |
simd | 8 | adjoint, 5 | regenerated | 4.29 | 3.12 | 194 / 522 MB |
simd + DRAW_CACHE | 8 | adjoint, 5 | cached | 7.69 | 3.97 | 1.4 / 1.4 GB |
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; everything else in those rows is the JVM.
Read the two bold columns first, then the memory column, then the asterisk.
Among the five quant libraries, the best Greeks pass is TensorFlow's 0.329,
and simd with the cache is 12.1× it — the same ratio as on the European,
to within rounding, which is worth pausing on. On the European the gap was
memory bandwidth on a short tape; here it is memory bandwidth on a long one.
TensorFlow's
sample_paths over 252 times is 252 vectorised steps, each a full pass over
200,000 doubles, then a reduce_mean over the (200,000 × 252) matrix and a
GradientTape that has to keep enough of that matrix to sweep back through
it: 2.8 GB of resident set for a 200,000-path ladder, against 119 MB for the
compiled tape without the cache and 1.3 GB with it. The draw block is worth 46% on the price
pass here rather than 16%, because on 252 draws per path the RNG is a much
bigger share of the streaming — finmath and cpu-jit both measured 2.7× on
this tape, and TensorFlow's 1.46× is the same effect diluted by everything
else that also streams.
QuantLib is 91× behind cpu-jit per core on this tape. 0.033 million
paths per second for a price, 0.0056 with five bumped Greeks, on one
thread; cpu-jit on one thread with the cache does 0.51 on the Greeks pass.
That is 252 steps of virtual evolve(), each pulling drift and diffusion from
term structures through a Handle, building a Path of 253 points, handing
it to a PathPricer that walks it again, per path, and the arithmetic is a
rounding error in the middle of the bookkeeping. QuantLib-Risks turns the six
valuations into one tape and lands at 0.0053 against 0.0056 — the tape now
saves almost exactly as much as it costs — while needing 3.5 GB to hold 20,000 paths of it.
A 200,000-path ladder would be 35 GB.
financepy's Asian kernel is the fastest single-threaded number in the table
that is not NablaTensor. 0.115 million paths per second, price only, on one
thread; 3.5× QuantLib. It is a numba loop over a (num_paths,) array per
step, antithetic, with the geometric-average control variate applied for free —
the array-of-paths design again, in a compiled loop with no dispatch, and it
is honest work. It has no threads on this product and no adjoint, so its
Greeks pass is six of those and lands at 0.020.
pfhedge is memory-bound like TensorFlow at a third of the speed, 2.6 GB
of resident set for the same 200,000 paths, cumsum over a (200,000 × 253) matrix and back through
autograd; 0.115 on the Greeks pass, with four of them.
Three designs, one ranking. Every quant library in this article is one of three things. One path at a time in a general object model — QuantLib, its XAD build, RustQuant: correct, flexible, and paying for virtual dispatch and heap traffic on every step. One operation at a time over an array of paths — TensorFlow, PyTorch, numba, finmath from the last article: no dispatch cost, easy threads, and every intermediate materialised in DRAM, which is fine on a one-step tape and 2.8 GB on a 252-step one. One path at a time in a compiled kernel — the NablaTensor tape: the per-path layout without the object model, so the intermediates live in L1 and the parallelism is across paths. On the European the second design ties the third per core and loses on threads; on the Asian it loses on memory bandwidth too. The first design loses on both tapes, and on the Asian by two orders of magnitude.
Greeks without a seed
The asterisk deserves its own section, because it is the most useful negative result in this piece and it has nothing to do with speed.
RustQuant's price_monte_carlo draws from rand::thread_rng(). There is no
seed field in StochasticProcessConfig, no way to pass a generator, and
thread_rng reseeds itself from the operating system. So a delta by bumping
is (V(S+h, draws₁) − V(S, draws₀)) / h with two independent sets of
200,000 paths, and the difference is dominated by the Monte-Carlo noise of the
two prices divided by h = 0.01:
The other bump libraries here — QuantLib, financepy — replay the same seed on
the bumped valuation, so the noise cancels and the finite difference is a
Greek. That is the whole point of common random numbers, and it is the
precondition for bump-and-revalue, not an optimisation of it: a library that
cannot fix its draws cannot produce bumped Greeks at any path count. It is
also the same reason DRAW_CACHE exists, one level down — the draw-cache
article's whole argument was that once the draws are a pure function of
(path, seed), keeping them is free. RustQuant's draws are not a function of
anything the caller controls.
Did they agree?
Different generators, different noise; what should agree is each estimate within its own standard error, and the Greeks within theirs.
| price | delta | dV/dK | vega | rho | dV/dT | |
|---|---|---|---|---|---|---|
| European, closed form (financepy, and Strata from the last article) | 9.4134 | 0.59871 | −0.50457 | 38.667 | 50.457 | 5.380 |
| European, QuantLib, 20 M, bump | 9.4136 | 0.59869 | −0.50437 | 38.673 | 50.453 | 5.378 |
| European, QuantLib-Risks, 2 M, adjoint | 9.4341 | 0.59885 | −0.50451 | 38.794 | 50.451 | — |
European, tf-quant-finance, 20 M, GradientTape | 9.4173 | 0.59879 | −0.50461 | 38.687 | 50.461 | 5.383 |
| European, pfhedge, 20 M, autograd | 9.4159 | 0.59893 | −0.50477 | 38.674 | 50.477 | — |
| European, RustQuant, 20 M, one-step Euler, bump on fresh draws | 9.2856 | 0.839 | −0.232 | 43.9 | 83.1 | 7.36 |
European, cpu-jit, 20 M, adjoint | 9.4122 | 0.59870 | −0.50458 | 38.661 | 50.458 | 5.380 |
| Asian, QuantLib, 200 k, bump | 5.2955 | 0.56230 | −0.50897 | 22.352 | 23.618 | 2.44 † |
| Asian, QuantLib-Risks, 20 k, adjoint | 5.3167 | 0.56357 | −0.51041 | 22.473 | 23.676 | — |
| Asian, financepy, 200 k, bump | 5.2778 | 0.56179 | −0.50870 | 22.284 | 23.500 | 2.932 |
Asian, tf-quant-finance, 200 k, GradientTape | 5.3310 | 0.56192 | −0.50861 | 22.565 | 23.575 | 2.964 |
| Asian, pfhedge, 200 k, autograd | 5.2938 | 0.56307 | −0.51013 | 22.331 | 23.672 | — |
| Asian, RustQuant, 200 k, 253-point average, bump on fresh draws | 5.2833 | 0.604 | −1.40 | −142.5 | −16.7 | −2.95 |
Asian, cpu-jit, 300 k, adjoint | 5.3010 | 0.56163 | −0.50862 | 22.386 | 23.591 | 2.946 |
† QuantLib's Asian dV/dT is a bump of the date grid by one day, and 252
fixings on integer days do not stretch uniformly; the number is rough and I
left it in rather than tune the bump until it matched.
The European standard error at twenty million paths is 0.0032 and every
20-million-path estimate is within 1.3 of it of the closed form; QuantLib-Risks
at two million paths has a standard error of 0.010 and its price sits 2.1 of
them above, on the last of the three ladders' seeds — that is what one seed
looks like, and its adjoint delta is within 0.0002 of the closed form
regardless. The Asian's standard error is 0.014 at 300,000 paths and 0.017 at
200,000; the six non-RustQuant estimates span 5.278 to 5.331, 3 standard
errors end to end, with a 1.7-standard-error outlier from TensorFlow's
stateless generator on one seed. The Greeks agree to three figures wherever
two libraries overlap. RustQuant's price is fine — the average of 253 points
including S₀ is a slightly different product, and 5.283 is the right number
for it — and its Greeks are the section above.
What to actually use
Same list as the Java article, re-ranked by what decided this one:
- Whether the library can run the product, as shipped. financepy's
European Monte Carlo returns
NaN. RustQuant's Greeks are noise. pfhedge has no Asian and no rate until you add them. Two of five libraries needed a workaround before the first number, and one of five cannot produce a bumped Greek at all. Nothing else on this list matters to a row that does not run. - Threads. TensorFlow's 8-thread 27.6 against QuantLib's 1-thread 1.00 on the European is mostly the thread count; RustQuant's 33.6 on the price pass is only the thread count, since per core it is a QuantLib-class design.
- Where the intermediates live. On the Asian, the array-of-paths
libraries — TensorFlow, PyTorch — pay 2.6–2.8 GB and a memory-bandwidth
ceiling that eight cores cannot lift;
cpu-jiton eight threads with the cache is 9.3× TensorFlow's best on the Greeks pass, at 1.3 GB against 2.8, andsimd12.1×. On the European the same gap is 5.4× and 12.4×. The ratio is stable because the bottleneck is the same on both tapes. - The draw cache. 16% for TensorFlow on the short tape, 46% on the long
one; 1.8× and 2.7× for
cpu-jit. Same lever, and the size of the win is a direct readout of how much of the kernel is not memory traffic. - Adjoint Greeks. TensorFlow and PyTorch pay 1.4–2.4× a price pass for every Greek at once, the same bound the tape engines pay; XAD pays 3.3–4.3×, which is what recording a tape through a library not written for one costs. QuantLib and financepy bump, and pay six valuations. RustQuant has an autodiff crate that its Monte-Carlo pricer does not use.
If your book is closed-form products in Python, financepy's value() and
QuantLib's analytic engines are the right tools and the only cost is Python.
If your book is path-dependent and you are already in TensorFlow or PyTorch,
tf-quant-finance is the strongest open-source thing measured here — archived,
protobuf workaround and all — and the ceiling you will hit is DRAM, not the
library. If your book is path-dependent and single-threaded C++ correctness
is what you need, QuantLib is QuantLib, and QuantLib-Risks will give you
adjoints for it at a memory price you must budget for per path. If your book
is path-dependent, your tapes are long and you have cores and would rather
not check, the compiled tape is 12× the best of the five open-source
libraries here on this box, on the CPU, before anyone touches a GPU.
Try it
Everything is in
bench/five-libraries-outside-the-jvm-one-ladder
on the main engine repo: one Python harness per library sharing a
ladder.py, a Cargo project for RustQuant, and run_all.sh, which produced
every non-NablaTensor row in the two main tables in one sitting.
git clone https://github.com/nablatensor-dev/nablatensor
cd nablatensor && git checkout bench/five-libraries-outside-the-jvm-one-ladder
cd bench/five-libraries-outside-the-jvm-one-ladder
uv venv --python 3.12 venv && source venv/bin/activate
uv pip install -r requirements.txt # QuantLib, QuantLib-Risks, tf-quant-finance, tensorflow-cpu, financepy, pfhedge, torch
(cd rustquant && cargo build --release)
VENV=venv ./run_all.sh # ~45 minutes, one process at a time; QuantLib's Asian is most of it
The QuantLib-Risks adjoint through a Monte-Carlo engine, in full, because it
is the least documented thing in this article and it took me two tries — the
Tape has to be the one QuantLib_Risks patches, not xad's own:
import QuantLib_Risks as ql # before xad: it patches Tape.activate
from xad.adj_1st import Real
with ql.Tape() as tape:
s0, k0, v0, r0 = Real(100.0), Real(100.0), Real(0.20), Real(0.03)
for x in (s0, k0, v0, r0): tape.registerInput(x)
tape.newRecording()
spot, vol, rate = ql.SimpleQuote(s0), ql.SimpleQuote(v0), ql.SimpleQuote(r0)
process = ql.BlackScholesProcess(ql.QuoteHandle(spot),
ql.YieldTermStructureHandle(ql.FlatForward(today, ql.QuoteHandle(rate), dc)),
ql.BlackVolTermStructureHandle(ql.BlackConstantVol(today, ql.NullCalendar(), ql.QuoteHandle(vol), dc)))
option = ql.DiscreteAveragingAsianOption(ql.Average.Arithmetic, 0.0, 0, fixings,
ql.PlainVanillaPayoff(ql.Option.Call, k0), ql.EuropeanExercise(fixings[-1]))
option.setPricingEngine(ql.MCDiscreteArithmeticAPEngine(process, "pseudorandom", requiredSamples=20_000, seed=seed))
v = option.NPV()
tape.registerOutput(v); v.derivative = 1.0; tape.computeAdjoints()
delta, dK, vega, rho = s0.derivative, k0.derivative, v0.derivative, r0.derivative
And the TensorFlow draw block, which is the one idea from this article that transfers to a codebase that is not NablaTensor:
draws = tf.random.stateless_normal([paths, 252, 1], seed=[seed, 0], dtype=tf.float64) # once per seed
for spot in (98, 99, 99.5, 100, 100.5, 101, 102):
paths_t = gbm.sample_paths(times, initial_state=spot, num_samples=paths, normal_draws=draws)
Watch peak RSS in the harness output before you raise the path count: for
three of the five libraries the ladder's memory, not its speed, is what sets
the ceiling.
The testing harness and code for this article — all six library harnesses,
the NablaTensor CrnLadder side against the Maven Central 0.1.0 artefacts,
run_all.sh, mem.sh, and the two logs every number above came from — is on
the bench/five-libraries-outside-the-jvm
branch of the NablaTensor repository.