QEncode Benchmark
← Blog
·6 min read·QEncode Team

The Four Things That Make a VQE Result Reproducible

Two weeks ago we found that some of our own published numbers were partly luck — threaded arithmetic was quietly rolling dice underneath a gradient-free optimizer. Fixing it raised an obvious next question: what would we have needed to check in advance to know a result was reproducible? It turns out to be four things, not one. So we wrote them down, and built a small tool that checks them on your own code.

The short version

A VQE number is reproducible only if four things are true: the arithmetic is deterministic (single-threaded BLAS), and the versions, the seed, and the code version are all recorded. One is about how the CPU adds numbers; three are about writing things down. Setting a random seed — the thing most people reach for first — fixes none of the hard one. We packaged all four into a free checker you can run in ten seconds: python check_vqe_reproducibility.py.

"Reproducible" gets used as though it were a single switch. It is not. A result reproduces when someone else — or you, six months later, on a different machine — can run the same thing and get the same number to the digits that matter. That requires the computation to be deterministic and the recipe to be fully written down. Those are separate failures, and most published VQE results miss at least one.

1. Deterministic arithmetic

This is the one almost nobody checks, and it is the one that bit us. Threaded BLAS — the linear algebra under NumPy and SciPy — splits a sum across CPU cores and combines the partial results in whatever order the threads finish. Floating-point addition is not associative, so the answer changes in its last bits depending on core count and machine load. A gradient-free optimizer such as COBYLA, Nelder-Mead or Powell chooses its next step by comparing energies, so that 1e-16 noise decides the direction whenever two candidates are close — and on a multi-modal landscape one different step early lands you in a different local minimum. The same command returned 8.99 mHa or 0.53 mHa for us, depending on nothing but how busy the machine was.

# the fix — before `import numpy`, not after

import os

for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",

"NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):

os.environ[v] = "1"

import numpy as np # everything after this is deterministic

Gradient-based optimizers (L-BFGS-B, and our statevector ADAPT engine) are effectively immune: a perturbation at 1e-16 moves the computed search direction by 1e-16, it does not flip a decision. If you can use analytic gradients, this whole problem mostly disappears — and in our runs L-BFGS-B was also roughly 20× faster than COBYLA.

2. Recorded package versions

A result that only reproduces on your exact stack is not reproducible unless that stack is written down. VQE code sits on numpy, scipy, and a quantum library or two (PennyLane, Qiskit, OpenFermion, PySCF), and those move. During our own audit two of our machines had quietly drifted across four packages — one of them a whole major version — without anyone noticing. Pin them — a requirements.txt with == versions, or an environment.yml — and keep it next to the results.

Worth saying plainly, because the story above makes it tempting to blame versions: a version drift changes how many optimizer steps you take, but pinned threads plus different NumPy still landed on the same energy to seven figures for us. Versions matter for reproducibility, but they were not the source of the non-determinism. Record them anyway — the next person cannot rebuild your run without them.

3. A recorded random seed

Set a seed for every stochastic step — parameter initialization, any sampling — and record its value, not just the fact that you set one. This is the condition people reach for first, so here is the trap: a seed does not fix condition 1. It controls the random draws in your code; it does nothing about the order in which BLAS adds floats across threads. We have seen runs with a fixed seed still return a seventeen-fold spread, purely from threading. A seed is necessary. It is nowhere near sufficient.

4. A recorded code version

The code that produced the number has to be recoverable. In practice that means a clean git commit: everything committed, and the commit hash stored with the result, so "the code that ran" is not "whatever was in my working directory that afternoon." An uncommitted change is invisible to anyone trying to reproduce you — including future you.

A tool that checks all four

We already enforce these four inside our own pipeline: it refuses to write a certified entry unless BLAS is single-threaded, the git tree is clean, and every pin matches what is importable. We pulled that logic out into a standalone, dependency-light script — NumPy and SciPy only — so anyone can point it at their own setup:

$ python check_vqe_reproducibility.py

Determinism — what we can check for you

[✗] BLAS single-threaded

64 threads — AT RISK: gradient-free optimizers can

return different energies run to run. Seed does not help.

Provenance — what you must record

[i] exact versions ... numpy 1.26.4, scipy 1.17.0, pennylane 0.44.1

[?] random seed ...... can't see your code; set AND record one

[✓] code version ..... clean git tree @ a1b2c3d

Your arithmetic is NON-DETERMINISTIC. Fix threads (below).

The verdict on threading is a hard yes/no — it reads your actual BLAS thread count — and it prints the exact fix if you are at risk. The three provenance checks are honest about their limits: it shows your installed versions and looks for a pin file, it reports your git state, and for the seed it simply reminds you, because it cannot read your code. It does not pretend to certify you; it tells you which of the four conditions it can see, and flags the rest.

Add --record and it writes a small qencode_reproducibility.json — platform, thread count, every detected package version, and the git commit — a provenance receipt you can save alongside your results so the environment that produced them can be verified later. Add --live and it will attempt an actual reproduction (a small VQE at one thread versus many); it is candid that a quiet result is not a clean bill of health, because whether the failure surfaces depends on the BLAS build, core count, and load.

The ten-second version

If you run VQE and want one thing to do today: run the same configuration three times and look at whether the digits agree. If they drift, you have a determinism problem, and it is almost certainly threading. Then set OMP_NUM_THREADS=1 before you import NumPy, pin your versions, record your seed and your commit — and you have all four.

Run it on your own code

The checker is in the public repository at tools/check_vqe_reproducibility.py. Free to use and share. The full story of how we found the threading problem in our own numbers is in the companion post.