The official QuantLib wheel prints different last digits on different Linux systems
We installed the same official QuantLib 1.43 wheel from PyPI on three Linux systems and computed the same
twelve values. On Ubuntu 24.04 (glibc 2.39) and Debian 13 (glibc 2.41) every result matched to the last bit.
Inside the manylinux_2_28 image (AlmaLinux 8.10, glibc 2.28), five of the twelve did not, and a
local-volatility price changed in its seventh significant digit. The code, the inputs and the QuantLib binary
were the same. The difference is the system's maths library: the wheel calls exp, log and pow from the
glibc it runs on, and glibc 2.28 rounds about one result in a thousand differently from glibc 2.39. Below are
the measurements, a script to check your own machines, and what to do about it.
What we ran
ql_last_digits.py prices a few options on fixed market data and prints
each result with 17 significant digits and as a hex float, so comparing two outputs compares bits. Its inputs
are built with + and * only, so they are the same bits on every machine. It also hashes exp, log and
pow from the system maths library over one million fixed inputs each.
| CPU | System | glibc | Python | |
|---|---|---|---|---|
| A | AMD Threadripper PRO 5975WX (Zen 3) | AlmaLinux 8.10 (the quay.io/pypa/manylinux_2_28_x86_64 image) |
2.28 | 3.12.13 |
| B | the same machine | Ubuntu 24.04 | 2.39 | 3.12.3 |
| C | AMD Ryzen 9 7950X3D (Zen 4) | Debian 13 | 2.41 | 3.13.5 |
All three used the PyPI wheel quantlib-1.43-cp39-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
(sha256 35cd2c17…), and the installed libQuantLib and _QuantLib files were byte-identical on all three.
A and B ran on the same CPU, so the processor is not what changes between them.
What changed
B and C printed identical output. A differed in five of the twelve values:
| Value | glibc 2.28 (A) | glibc 2.39 and 2.41 (B, C) | Relative difference |
|---|---|---|---|
| American put, Longstaff–Schwartz | 6.5526994108438767 | 6.5526994108438759 | 1.4e-16 |
| Asian call, Monte Carlo | 6.5329120644316481 | 6.532912064431649 | 1.4e-16 |
| Down-and-out call, Monte Carlo | 9.5748933379022443 | 9.5748933379022514 | 7.4e-16 |
| European put, local volatility, Monte Carlo | 8.0916614100380553 | 8.0916636154283292 | 2.7e-7 |
| European put, local volatility, Sobol paths | 8.2206887394419095 | 8.2206876199600138 | 1.4e-7 |
The other seven (a discount factor, a process drift, a Black–Scholes price and delta, a Monte Carlo European
price, a binomial-tree American price and a Heston formula price) were identical on all three systems. A
result changes only if one of the exp, log or pow calls behind it hits an input that the two libraries
round differently, so some prices move and others do not.
Local volatility moves the most because QuantLib derives it from the implied-volatility surface by finite differences: a one-ulp change divided by a small step is no longer small. Anything else that differentiates numerically, such as bumped Greeks or calibration Jacobians, can amplify the difference in the same way.
A larger C++ test program of ours prints 867 lines through the same official library, 840 of them numbers,
and shows the same pattern. Between glibc 2.28 and 2.39, 205 of the 840 numbers (24%) differ, and so do 6
hashes of generated paths; between glibc 2.39 and 2.41 nothing differs. 201 of the 205 numbers differ by less
than 1e-6 relative. The largest difference, 6e-4, is again a local-volatility price
with Sobol paths; that program also builds its volatility surface with log, so its inputs already differ.
The cause: the system's maths library
Linux wheels on PyPI follow the manylinux rules: they may bundle most libraries, but never glibc and its
maths library libm, which come from the system at run time. pip install QuantLib==1.43 pins the QuantLib
binary, not the code that computes exp(x).
The script's check of the maths library, one million inputs per function:
| Function | Results that differ, glibc 2.28 vs 2.39 | By | glibc 2.39 vs 2.41 |
|---|---|---|---|
exp |
984 (0.098%) | 1 ulp | 0 |
log |
1,276 (0.128%) | 1 ulp | 0 |
pow |
761 (0.076%) | 1 ulp | 0 |
An ulp is one unit in the last place of a 64-bit float. Both versions are accurate: we compared every
differing result with the exact value computed to 50 digits, and all of them are within 0.53 ulp of it.
Neither version is "the right one". For log and pow the older glibc 2.28 was the closer one in almost
every disagreement; for exp, in 712 of the 984.
To check that these three functions explain the whole difference, we preloaded a small library,
shim.c, that replaces exp, log and pow with the same code on every system. With
it, all twelve values were identical on A, B and C. It is a diagnostic, not a fix: its results differ from
every stock glibc.
The processor can matter too. glibc picks the code for these functions by CPU features. On machine B, with
glibc's FMA code switched off (GLIBC_TUNABLES=glibc.cpu.hwcaps=-FMA, the code a CPU without FMA would run),
three of the twelve values changed and exp gave a different result for 738 of the million inputs. We did not
test glibc versions between 2.28 and 2.39 (for example 2.34 in RHEL 9 or 2.36 in Debian 12), so we do not
know where the change happened.
Why it matters
None of these differences matters for a trading decision; the statistical error of a Monte Carlo price is far larger. They matter wherever results are compared exactly:
- UAT against production. A valuation on a RHEL 8 test server and on an Ubuntu 24.04 production host can disagree in the last digits, and a zero-tolerance reconciliation reports a break that no code change caused.
- Regression tests with golden files. A test that stores prices at full precision passes on the developer's machine and fails in CI, or starts failing after an operating-system upgrade.
- Audits and model validation. A validator who reruns the model on another system to reproduce a reported figure gets a different seventh digit and has to explain it.
- Caches and result hashes. A cache keyed by a hash of the results misses for no visible reason.
What to do about it
- Pin the maths library. Run the computation in a container image and record the image digest with the
results. The image brings its own glibc and
libm; the host's versions no longer matter. A container fixes the library, not the processor, so record the CPU model as well and test on the CPU family you rely on. - Compare with tolerances where bits are not the requirement. Size the tolerance to the numerics, not to
machine epsilon: one-ulp differences in
expbecame 3e-7 relative in the local-volatility price above. For Monte Carlo prices, a fraction of the engine's error estimate is a natural bound. - Record the environment with every result: the glibc version (
ldd --version, oros.confstr("CS_GNU_LIBC_VERSION")in Python), the operating system or image, the CPU model, and the Python and QuantLib versions. The script's first line records the glibc, Python and QuantLib versions. - When results must match bit for bit, decide which system is the reference and compare on that system, not on a similar one.
Check your own machines
python -m venv ql && ql/bin/pip install QuantLib==1.43
ql/bin/python ql_last_digits.py --dump this.bin > this.txt
Run the same two lines on another machine, or in a container with another glibc, for example:
docker run --rm -v "$PWD":/w -w /w quay.io/pypa/manylinux_2_28_x86_64 sh -c \
'/opt/python/cp312-cp312/bin/python -m venv /tmp/ql && /tmp/ql/bin/pip install -q QuantLib==1.43 &&
/tmp/ql/bin/python ql_last_digits.py --dump other.bin > other.txt'
Then compare:
python ql_last_digits.py --compare this.txt other.txt # the printed values
python ql_last_digits.py --compare this.bin other.bin # exp, log and pow, input by input
The script runs in a few seconds. Our outputs from every system are next to it, for example glibc-2.28.txt and glibc-2.39.txt, with all comparisons in comparisons.txt. The measuring machine had no container engine, so we ran the same image as a chroot in an unprivileged user namespace; Docker or Podman do the same.
Measured on 12 October 2026 with QuantLib 1.43 from PyPI.
Disclosure: TAKT sells faster builds of numerical libraries whose output is bit-identical to the original on the same system; see our QuantLib package and quantlib-takt on PyPI.
This note was drafted with an AI assistant from our measurements; the scripts were run on Linux as described above. Corrections are welcome at [email protected].
Optional. By enabling comments, you consent to using the comment service operated by TAKT (ABX DEVELOPMENT LLP) at comments.taktcycles.com, delivered through Cloudflare, and its connections to GitHub. The receiving services obtain connection data, including your IP address. Our service receives this note’s public address and description. The feature accesses any saved comment sign-in and, if you sign in, remembers it in this website’s local storage between visits, with no fixed automatic deletion period. You can read comments without signing in. Posts and reactions are public on GitHub; choosing Preview also sends your unpublished draft to GitHub. You can withdraw permission using “Disable comments and forget sign-in”. See our Privacy Notice and Recipients Schedule for details, including international transfers. You can read this note without enabling comments.
Discuss this note on GitHub. Comments live in a public GitHub Discussion. The link opens GitHub, where GitHub’s terms and privacy statement apply; reading needs no account, posting needs a GitHub account.