We Audited Our Own VQE Benchmark and Found the Numbers Were Partly Luck
We set out to certify H₁₀ — a ten-atom hydrogen chain, 20 qubits, the largest system on the QEncode leaderboard. We got it: 9.977 mHa, 300 operators. But on the way we found something that mattered more. Run the same command twice and our benchmark returned different answers. Not because of a bug in our code, and not because of a version mismatch. Because of how a CPU adds numbers.
The short version
Threaded BLAS sums floating-point numbers in nondeterministic order. That perturbs VQE energies in their last bits. Gradient-free optimizers like COBYLA choose their next step by comparing energies, so the noise picks the direction and the optimizer lands in a different local minimum. The same LiH command returned 8.99 mHa or 0.53 mHa depending on nothing but CPU load. OMP_NUM_THREADS=1 makes it bit-for-bit reproducible. ADAPT-VQE with analytic gradients was never affected.
The number that would not sit still
It started with a single entry that refused to reproduce. Our H₆ hydrogen chain was published at a 9.755 mHa gap. Re-running it gave 9.273 mHa. Same molecule, same config, same seed.
The obvious suspect was a code change — we had recently switched ADAPT-VQE to commutator gradients. We checked out the old commit and re-ran. Still 9.273. So the code was innocent.
The second suspect was the environment. Our requirements-v4.txt pinned PennyLane 0.45.0, NumPy 2.2.6, PySCF 2.6.2, SciPy 1.13.1 — and both our machines had quietly drifted to PennyLane 0.44.1, NumPy 1.26.4, PySCF 2.5.0, SciPy 1.17.0. Twenty-one of our forty-six entries had been produced on an unpinned stack. That looked like the answer. We rebuilt the environment from the pin, re-ran, and got 9.755 — matching the published value.
Case closed. Except it was wrong.
Two samples of a coin flip
We had run each environment exactly once, got one number from each, and constructed a causal story out of it. When we ran the same environment repeatedly, both numbers appeared:
H₆ JW/ADAPT — identical command, identical stack, identical seed
OMP_NUM_THREADS=4, run 1 → 9.755353e-03 Ha
OMP_NUM_THREADS=4, run 2 → 9.272866e-03 Ha
Both "environments" had been giving us random draws from the same distribution. The 9.755 we had chased for hours was a coin flip that came up heads.
The real variable was the thread count. Pin it, and the noise vanishes:
LiH PAR/HEA — identical command, only OMP_NUM_THREADS varying
OMP_NUM_THREADS=1 → 5.181379e-03 5.181379e-03 5.181379e-03
OMP_NUM_THREADS=4 → 8.993703e-03 5.309440e-04 8.993703e-03
A seventeen-fold spread at four threads, and not even stable at a fixed thread count — it depended on how busy the machine was. At one thread: identical to the last digit, every time.
The decisive test
If threading was the cause, then two completely different software stacks should agree once threading is pinned. We ran H₆ at one thread on both:
PL 0.45.0 / numpy 2.2.6 / pyscf 2.6.2 / scipy 1.13.1
→ -3.3075959902 Ha (nfev 6023)
PL 0.44.1 / numpy 1.26.4 / pyscf 2.5.0 / scipy 1.17.0
→ -3.3075960058 Ha (nfev 5678)
Different PennyLane, different NumPy, different PySCF, different SciPy — agreement to seven decimal places. SciPy's COBYLA rewrite changes how many evaluations it takes (6023 versus 5678) but not where it lands. The environments never mattered. The threads did.
Why this happens
Floating-point addition is not associative. (a+b)+c and a+(b+c) can differ in the last bits. A threaded BLAS library splits a sum across cores and combines the partial results in whatever order the threads finish — which depends on scheduling, cache state, and what else the machine is doing. So the same energy evaluation returns values that differ at roughly 1e-16.
That is normally harmless. It stops being harmless when an optimizer uses those values to make decisions.
COBYLA is gradient-free. It has no derivative to follow, so it decides where to step by comparing sampled energies. When two candidate directions are nearly tied, a 1e-16 difference chooses between them. One different choice early in the run sends the optimizer down a different branch — and the hardware-efficient ansatz has a famously multi-modal landscape, full of local minima. The two branches end in different valleys. Hence 0.53 versus 8.99 mHa from the same command.
Gradient-based optimizers do not have this problem. L-BFGS-B and our statevector ADAPT engine compute a search direction from analytic gradients rather than inferring one from noisy comparisons. A perturbation at 1e-16 moves the computed direction by 1e-16 — it does not flip a decision.
That prediction is testable, and it held. H₈, run through the statevector engine with L-BFGS-B:
single-threaded → -4.4047495716 Ha 98 ops nfev 63072
multi-threaded → -4.4047495716 Ha 98 ops nfev 63072
Identical energy, identical operator count, identical evaluation count. The distinction is not "VQE is irreproducible" — it is specifically gradient-free optimization on a multi-modal landscape with nondeterministic arithmetic.
What it meant for our published numbers
This is the uncomfortable part. Our entries were real VQE runs — nothing was fabricated — but several were lucky draws rather than the value the configuration actually converges to. Re-running everything deterministically moved them:
published → deterministic (mHa)
N₂ JW/UCCSD 2.016 → 10.832 (lost certification)
NH₃ PAR/HEA 0.369 → 6.911
LiH PAR/HEA 0.410 → 5.181
BeH₂ PAR/UCCSD 0.003 → 2.223
H₄ JW/UCCSD 0.132 → 2.222
H₆ JW/ADAPT 9.755 → 9.273
and some improved:
N₂ PAR/HEA 14.261 → 9.504 (gained certification)
H₂O JW/HEA 0.084 → 0.000
NH₃ JW/UCCSD 0.244 → 0.032
The suite total is unchanged — 39 certified entries across 14 molecules — but that is partly coincidence and we would rather say so. Two entries crossed the threshold in opposite directions and cancelled out. N₂ JW/UCCSD, our 404-parameter run, was published at 2.016 mHa; its deterministic value is 10.832 mHa, just over the 10 mHa line, so it is now research tier. N₂ PAR/HEA went the other way, from 14.261 to 9.504, and is now certified. N₂ remains certified as a molecule through its ADAPT and JW/HEA entries.
The 2.016 mHa was a real run. It was also a lucky one. Losing it is the honest cost of this exercise, and it is worth more than keeping it would have been.
We verified the new values are actually reproducible rather than assuming it. We picked the four entries that moved most — the most fragile cases — and re-ran them: BeH₂ 2.222518, LiH 5.181379, NH₃ 6.911462, H₄ 2.221795. All bit-identical to the recorded values.
The fix
One line, with one subtlety: the thread limits must be set before NumPy is imported. After that, BLAS has already built its thread pool and the variables are silently ignored.
# at the very top of the module, before `import numpy`
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
os.environ[v] = "1"
We also made this a property of the entry rather than of whoever ran it. The pipeline now refuses to write a certified entry unless BLAS is single-threaded, the git tree is clean (so the recorded commit actually describes the running code), and every pin in requirements-v4.txt matches what is importable. Each entry records blas_threads in its provenance.
Suite v4.4: one commit, one stack, one method
We regenerated all 46 entries. The suite went from a patchwork to something uniform:
before → after
git commits 11 → 1
PennyLane versions 2 → 1
Hamiltonian paths 2 → 1 (and now recorded)
BLAS threads variable → 1
certified entries 39 → 39
The Hamiltonian path deserves its own mention. We had been silently running two: 25 entries built the qubit Hamiltonian from PySCF integrals via OpenFermion, and 19 used PennyLane's native molecular_hamiltonian, which runs its own Hartree-Fock and lands on a slightly different active space. The exact ground state of those Hamiltonians sat up to 3.6e-4 Ha from the CASCI energy the entries claimed to target, versus ~1e-15 Ha for the OpenFermion path. Nothing recorded which path an entry had used — we had to infer it from that energy fingerprint. The integral-based path is now the default, and hamiltonian_source is recorded per entry.
And H₁₀, since you asked
H₁₀ certified at 9.977 mHa with 300 operators — a [10e,10o] active space, 20 qubits tapered to 18, 14,227 Pauli terms, CASSCF orbitals. It is the largest certified system on the leaderboard.
Multi-threaded, H₁₀ returned 300, 200, then 300 operators across three runs. Single threaded it returns 300 every time. That matters, because the operator count is the interesting number:
ADAPT operators to reach the 10 mHa threshold (cc-pVDZ)
H₆ [6e,6o] 12 → 9 qubits 28 operators
H₈ [8e,8o] 16 → 13 qubits 98 operators (3.5×)
H₁₀ [10e,10o] 20 → 18 qubits 300 operators (3.1×)
Roughly 3.2× per two added orbitals. That is exponential growth in the ansatz size, and it is consistent with recent work arguing that ADAPT-VQE parameter counts scale exponentially with system size. Those analyses generally report the trend without publishing per-molecule operator counts at a fixed accuracy threshold. Ours are above, and the full entries — including every selected operator index and the serialized Hamiltonian — are downloadable, so the numbers can be checked rather than taken on trust.
One caveat we will not paper over: these counts are at our 10 mHa certification threshold, not at the 1.6 mHa chemical-accuracy threshold used elsewhere. Tighter thresholds need more operators. The scaling is the point, not the absolute values.
What this means if you run VQE
Threaded BLAS is on by default nearly everywhere. NumPy, SciPy, PennyLane and PySCF will happily use every core you have. If your optimizer is gradient-free — COBYLA, Nelder-Mead, Powell — and your ansatz landscape is multi-modal, your published number may be a sample rather than a result. It costs one afternoon to check: run the same configuration three times and see whether the digits agree.
Concretely:
- Set
OMP_NUM_THREADS=1before importing NumPy, and record it alongside your package versions. - Prefer gradient-based optimizers where you can. Beyond the reproducibility argument, L-BFGS-B was roughly 20× faster than COBYLA in our H₁₀ runs and reached the same energies.
- If you must use a gradient-free optimizer, report a distribution rather than a single value — or pin your threads and say so.
Why we published this
It would have been easy not to. The bug was invisible, no one had complained, and the fix quietly makes several of our headline numbers worse. We could have re-run everything and said nothing.
But a benchmark that has never reported bad news about itself is not a benchmark anyone should trust. We pin package versions, hash every entry with SHA-256, and sign certified results with Ed25519 — and underneath all of that, the arithmetic was rolling dice. A user could match our commit, match our stack, run our exact command, and legitimately get a different answer because their CPU had a different core count. That is precisely the failure this project exists to prevent.
The whole point of the checks we found this with is that they were written down before we looked. We predicted that the 25 already-correct entries should come back identical; they did not, and that is what exposed a bug in our own re-run script. A benchmark should be the thing that catches this, including when the thing it catches is itself.
Check it yourself
Every Suite v4.4 entry — energies, run config, provenance, the serialized Hamiltonian, and every selected ADAPT operator — is in the public repository. The reproducibility guard is in scripts/generate_entry_v4.py.