Skip to content

examples: SQD driver that grows its subspace via SBD's own carryover - #39

Open
Sophia Wen (hfwen0502) wants to merge 4 commits into
mainfrom
sbd-carryover
Open

Sophia Wen (hfwen0502) wants to merge 4 commits into
mainfrom
sbd-carryover

Conversation

@hfwen0502

@hfwen0502 Sophia Wen (hfwen0502) commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds a third SQD driver that grows its subspace using SBD's own carryover
(MPI-distributed C++) instead of qiskit-addon-sqd's JAX single excitations,
plus the sbd_solver.py plumbing to get those determinants back out of SBD.

The gap this closes. tpb_diag has always returned
carryover_adet/carryover_bdet in its results dict — bindings.cpp unpacks
SBD's co_adet/co_bdet into it — but _solve_sci_core discarded them, and
_create_sbd_config force-set carryover_type = 0 because they were
discarded (its comment said so outright). Same shape as the RDM gap fixed in
#36: SBD computed the thing, the wrapper threw it away.

  • extract_carryover() converts them out of SBD's packed half-determinant
    format into plain CI strings via _sbd_dets_to_ci_strings, so they can feed
    include_configurations directly.
  • SCIResult is upstream's frozen dataclass, so a carryover field cannot
    be added the way rdm1/rdm2 were (those already existed). Hence
    SBDCarryoverResult, a subclass.
  • carryover_type = 0 (the default) still returns a plain SCIResult, so the
    normal SQD path is unaffected and pays nothing.

Why the subclass is safe. qiskit-addon-sqd's loop reads only
.energy/.sci_state/.orbital_occupancies, selects with
min(results, key=...), and passes that same object through — SCIResult( is
constructed exactly once in all of fermion.py, inside upstream's own PySCF
solver. Verified on 4 ranks that the subclass survives both the loop and
upstream's rank-0-to-all-ranks broadcast with carryover intact.
_assert_carryover_survived turns that assumption into a loud failure rather
than a silent one, since a stripped result would make a driver quietly stop
expanding while still reporting convergence.

Docs. examples/README.md's parameter tables had drifted: the section
claimed to cover "every flag run_sqd_sbd.py accepts" while actually mixing
three drivers with three different scoping conventions, and the newest driver
appeared nowhere. Restructured into shared-flags / per-driver-defaults /
per-driver-expansion-knob tables with explicit short names, and added a
Tuning the expansion threshold section carrying a measured H2O sweep plus
the --max_dim trap (once pinned at the cap, the cap random-fills, so a lower
threshold can move the energy the wrong way).

Verification (h100, 8× H100)

energy subspace rounds
this driver -76.2421767512 1742² 2
run_sqd_enlarge_subspace_sbd.py -76.2421767512 1742² 3

Identical to all ten digits over an identical final subspace — the real
correctness check, since SBD carryover type 3 and
enlarge_batch_from_transitions should compute the same expansion when
embedded in the same loop. They also agree bit-for-bit at threshold 1e-5
(-76.2436018956, 5007²) and 1e-6 (-76.2437251036, 7881²).

  • Runs on 8 GPU ranks with JAX_PLATFORMS unset — the exact configuration
    where the JAX driver dies with CUDA_ERROR_OUT_OF_MEMORY, since no JAX is
    involved in the expansion at all.
  • --sbd_carryover_type 0 is rejected up front rather than silently looping
    forever with nothing to expand.
  • run_sbd_diag.py still runs with qiskit-addon-sqd and qiskit
    import-blocked (verified the shim actually hid them, so the check wasn't
    vacuous) — the new subclass sits behind the existing try/except ImportError
    guard, and its only other references are inside function bodies.

The round-count difference has a specific cause worth noting: the JAX driver
compares its 1720-string expansion against the 1742-string solved subspace,
so its no-growth test can never fire and it always waits for energy
convergence. This driver unions the carryover with the solved subspace first,
so closure is detected directly.

On performance — measured, and the earlier claim was wrong. Benchmarked
head to head on a 45-orbital system (8 ranks, --max_dim 15000, threshold
1e-4), this driver and the JAX one agree round for round — identical
energy, subspace size and expansion size at all 8 rounds, both converging to
-299.4859985587 over 15000² — in 2248 s vs 2250 s. So there is no speed
advantage: each round is dominated by configuration recovery and by
diagonalizing a 225M-determinant subspace, and where the expansion runs is
not measurable. An earlier "2-4x faster" note in the docstring was a
misattribution (it compared against a driver with no configuration recovery,
so it measured recovery overhead) and has been corrected here and in both
READMEs. The reason to prefer this driver is operational — no JAX in the
expansion path, so no inter-rank GPU contention and no JAX_PLATFORMS=cpu
workaround — explicitly not speed.

That benchmark also caught a real bug, now fixed: the driver unioned the
carryover with the solved subspace before capping, which deadlocks once
--max_dim binds (the cap keeps existing determinants first, so the union
consumes the whole budget and the subspace freezes, with --energy_tol
reading the frozen energy as convergence). It landed 3.41 Ha high. No-growth
is now a subset test instead, needing no union. Not preserving the solved
subspace is correct — pruning is what carryover is for, and the JAX driver
prunes the same way, which is exactly why it never hit this. Unreachable with
--max_dim unset, which is all H2O exercised.

Notes for review

  • The net change is 6 files; the branch's 23 commits include the
    SBD-native selected-CI driver being added and later removed, since the
    carryover driver supersedes it for this package's SQD focus. Squash-merge
    collapses that, but commit-by-commit review will look noisier than the diff.
  • bindings.cpp gains the #ifdef-guarded extended-carryover fields. Those
    are deliberately not advertised--sbd_eri_threshold is
    argparse.SUPPRESSed and --sbd_carryover_type's help stays vendor-neutral
    about values past 3 — since they need a build this repo does not ship. They
    remain reachable for anyone whose build has them.

tpb_diag has always returned carryover_adet/carryover_bdet in its results
dict -- bindings.cpp unpacks SBD's co_adet/co_bdet into it -- but
_solve_sci_core discarded them, and _create_sbd_config force-set
carryover_type to 0 *because* they were discarded, as its own comment said.
Structurally the same gap the RDMs had: SBD computes the thing, the wrapper
throws it away.

extract_carryover() converts them out of SBD's packed half-determinant
format into plain CI strings via _sbd_dets_to_ci_strings, so they can feed
include_configurations directly.

SCIResult is upstream's frozen dataclass, so a carryover field cannot be
added the way rdm1/rdm2 were -- those already existed. Hence
SBDCarryoverResult, a subclass. This is safe because qiskit-addon-sqd's loop
reads only .energy/.sci_state/.orbital_occupancies, selects with
min(results, key=...), and passes that same object through: SCIResult( is
constructed exactly once in all of fermion.py, inside upstream's own PySCF
solver. Verified on 4 ranks that the subclass survives both the loop and
upstream's rank-0-to-all-ranks broadcast with carryover intact.
_assert_carryover_survived turns that assumption into a loud failure rather
than a silent one, since a stripped result would make a driver quietly stop
expanding while still reporting convergence.

carryover_type = 0 (the default) still returns a plain SCIResult, so the
normal SQD path is byte-for-byte unaffected and pays nothing. Confirmed
run_sbd_diag.py still runs with qiskit-addon-sqd and qiskit both absent --
the subclass sits behind the existing try/except ImportError guard and its
only other references are inside function bodies.

bindings.cpp also exposes the extended-carryover fields, kept behind the
existing #ifdef since binding them unconditionally fails to compile against
plain upstream.
…over

Third SQD driver. Same outer-loop structure as
run_sqd_enlarge_subspace_sbd.py -- diagonalize_fermionic_hamiltonian called
with max_iterations=1 in its own loop, expanded determinants fed forward as
the next round's include_configurations -- with the expansion step swapped.
Instead of qiskit-addon-sqd's JAX enlarge_batch_from_transitions, the new
determinants come from SBD itself, selected inside the same C++
diagonalization that just ran.

Verified on H2O (bundled 275-bitstring pool, 8 ranks): -76.2421767512 over a
1742x1742 subspace, identical to all ten digits and over an identical final
subspace to run_sqd_enlarge_subspace_sbd.py. That is the real correctness
check -- SBD carryover type 3 and enlarge_batch_from_transitions should
compute the same expansion inside the same loop. They also agree bit-for-bit
at threshold 1e-5 (-76.2436018956, 5007^2) and 1e-6 (-76.2437251036, 7881^2).

Two practical differences, both following from the expansion running in
MPI-distributed C++ rather than JAX on every rank:

- Runs on 8 GPU ranks with JAX_PLATFORMS unset -- the exact configuration
  where the JAX driver dies with CUDA_ERROR_OUT_OF_MEMORY, since several
  ranks each try to claim a device. No JAX is involved here at all.
- Detects closure a round earlier. The JAX driver compares its 1720-string
  expansion against the 1742-string solved subspace, so its no-growth test
  can never fire and it always waits for energy convergence; this driver
  unions the carryover with the solved subspace first.

--sbd_carryover_type 0 is rejected up front rather than looping forever with
nothing to expand. Values past 3 are accepted and passed through, but not
advertised, since they need a build this repo does not ship;
--sbd_eri_threshold is likewise suppressed from --help.

Not yet demonstrated: the expansion-speed advantage. Per-round time is a
wash at H2O size (38.02s vs 38.43s), because the expansion is negligible
against a 3M-determinant diagonalization.
… drivers

examples/README.md's parameter reference had drifted. It claimed to cover
"every flag run_sqd_sbd.py accepts" while actually mixing three drivers, and
scoped them three different ways: prose "X only" in some cells, split
defaults like "1 (enlarge) / 3 (sqd)" in others, nothing at all in the rest,
so a reader had to infer which flags were universal. The newest driver
appeared nowhere. Fine at two drivers, confusing at three.

Restructured around an explicit split: a short-name table up front mapping
each driver to how its subspace grows; "same flag, same default, in all
three"; "same flag, different default in each" with one column per driver;
and "how the subspace grows -- one knob per driver, NOT interchangeable",
since --sqd_carryover_threshold, --enlarge_threshold and
--sbd_carryover_threshold are easy to mistake for each other and only the
latter two are equivalent (at carryover type 3).

Added "Tuning the expansion threshold", which the tables previously left to
guesswork: a measured H2O sweep showing 1e-4/1e-5/1e-6 giving 1.60/0.175/
0.052 mHa against this system's FCI reference over 3.0M/25.1M/62.1M
determinants -- two orders of magnitude on the threshold buying 30x less
error at 20x the determinants, with sharply diminishing returns. Measured
with the carryover driver on the bundled pool, i.e. the exact documented
command. It also names the trap: --max_dim inverts the relationship, because
once pinned at the cap the cap random-fills from a larger candidate pool, so
a lower threshold can move the energy the wrong way.

In the top-level README the two enlargement drivers are now presented as one
pair rather than two unrelated bullets, in the Examples list and as a single
"SQD with subspace enlargement" subsection. They share the same outer-loop
structure and differ only in the expansion engine, so a table contrasts JAX
(no MPI awareness, needs JAX_PLATFORMS=cpu past one rank) against SBD
carryover (MPI-distributed, closes a round earlier), and says which to prefer
and when the JAX one is still right -- it is solver-agnostic, SBD's carryover
is not.

Also reworded both drivers' --max_dim comments to describe the situation
rather than name a specific internal dataset.
Two problems, both found by benchmarking on a 45-orbital system. H2O could
not have caught either.

1. The driver unioned SBD's carryover with the solved subspace before
   capping, so that the no-growth check had something to compare. But
   cap_to_max_dim keeps everything already in the subspace first, so once the
   solved subspace is itself max_dim strings the union makes existing consume
   the entire budget, every new candidate is discarded, and the subspace
   freezes. --energy_tol then reads the frozen energy as convergence. On a
   45-orbital system at --max_dim 15000 it "converged" after four rounds at
   -296.0744895995, 3.41 Ha above where the same expansion actually reaches.
   Invisible with --max_dim unset, which is all H2O exercised.

   Fixed by forwarding the raw carryover and deriving no-growth from a subset
   test (carryover subset of solved) instead of a union. Not preserving the
   solved subspace is correct, not a regression: pruning is what carryover is
   for, run_sqd_enlarge_subspace_sbd.py's expansion prunes the same way (its
   identity row preserves the thresholded pairs, not the whole subspace), and
   qiskit-addon-sqd's own carryover plus fresh sampling re-supply anything
   that still matters. That driver escapes the deadlock precisely because it
   does not union.

   After the fix the two drivers agree round for round on that system --
   identical energy, subspace size and expansion size at all 8 rounds,
   converging to -299.4859985587 over 15000^2. H2O unchanged at
   -76.2421767512 over 1742^2.

2. The docstring claimed the MPI-distributed expansion was "2-4x faster"
   than the JAX path. Measured head to head, it is not: 2248 s versus 2250 s.
   Each round is dominated by configuration recovery and by diagonalizing a
   225M-determinant subspace, so where the expansion runs is not measurable.
   The original figure came from comparing against a driver with no
   configuration recovery at all, so it measured recovery overhead and was
   attributed to the wrong thing.

   Reworded here and in both READMEs: the reason to prefer this driver is
   operational -- no JAX in the expansion path, so no GPU contention between
   ranks and no JAX_PLATFORMS=cpu workaround -- and explicitly not speed.
Comment thread python/sbd_solver.py
Comment on lines +59 to +63
class SBDCarryoverResult(SCIResult): # type: ignore[misc,valid-type]
"""SCIResult plus the carryover determinants SBD selected itself.

``SCIResult`` is upstream's frozen dataclass, so a new field cannot be
added to it -- hence this subclass. It is safe to hand back to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR has potential overlap with #32. In Qiskit/qiskit-addon-sqd#369, there is a new carryover field on the SCIResult. It would be great to converge on our thinking/plans next week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants