# 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](quantlib-glibc/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](quantlib-glibc/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 1. **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. 2. **Compare with tolerances where bits are not the requirement.** Size the tolerance to the numerics, not to machine epsilon: one-ulp differences in `exp` became 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. 3. **Record the environment with every result:** the glibc version (`ldd --version`, or `os.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. 4. **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 ```sh 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: ```sh 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: ```sh 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](quantlib-glibc/glibc-2.28.txt) and [glibc-2.39.txt](quantlib-glibc/glibc-2.39.txt), with all comparisons in [comparisons.txt](quantlib-glibc/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](/showcase/quantlib-mc/README.html) and [quantlib-takt on PyPI](https://pypi.org/project/quantlib-takt/). This note was drafted with an AI assistant from our measurements; the scripts were run on Linux as described above. Corrections are welcome at hello@taktcycles.com.