Skip to content

GDB: sharded determinant basis, numpy marshalling, drivers and first tests - #46

Merged
Jim Garrison (garrison) merged 26 commits into
mainfrom
gdb-sharding
Oct 9, 2026
Merged

Jim Garrison (garrison) merged 26 commits into
mainfrom
gdb-sharding

Conversation

@hfwen0502

Copy link
Copy Markdown
Member

Makes GDB usable for a sparse, distributed subspace. Experimental — GDB moves fast upstream and this will follow it.

Stacked on #44. Base is examples-reorg, so this diff is only the GDB work; GitHub retargets to main once #44 merges. Review #44 first.

Why

gdb_diag required every rank to pass the whole determinant list, so b_comm_size had to be 1. That cascaded: b is the only dimension that shards memory (h_comm is a row stride plus an allreduce; MakeHelpers ignores it), t <= b is structural, and Thrust throws unless h_comm_size == 1 — so multi-GPU GDB was unreachable and memory did not scale at all. Upstream's own run.sh uses --b_comm_size 2; the restriction was ours, not SBD's.

What changed

  • gdb_diag takes a per-rank shard. b_comm_size == 1 keeps today's meaning (whole basis); > 1 means this rank's shard, verified globally rather than trusted — shard identity across h_comm, local sort, cross-shard disjointness. All validation votes through MPI_Allreduce before any rank throws, so a bad input cannot deadlock.
  • numpy (N, words) in and out, via c_style | forcecast so existing list callers still work. Removes two of three copies; adds from_strings for bulk packing instead of one from_string call per determinant (issue Duplicate-determinant handling differs between TPB and GDB; cost of the #29 label conversion at scale #31 flagged that at 39k).
  • All six placement schemes as one determinant_distribution string. The three legacy booleans stay unexposed: they encode a four-way choice with a priority rule, and do_redist_alpha_eq defaults true so the others are dead unless it is zeroed.
  • Guardrails for two silent failures: t > b segfaults upstream (a starved rank dereferences an empty exidx) and t*b not dividing the rank count silently builds ragged communicators. Both now refuse up front.
  • Two drivers — run_gdb_diag.py and run_gdb_heatbath.py (a cutoff ladder; carryover_type 2/3 returns parents with candidates, so one round's result is the next subspace). In-memory throughout, no file round-trip.
  • Per-spin electron-count validation. Feeding qiskit-addon-sqd's concatenated [beta | alpha] strings where GDB wants them interleaved used to diagonalize to a plausible wrong energy and then abort inside the expansion with std::out_of_range. Now refused with the cause named. The occupation density cannot catch it — permuting bits preserves how many are set.

Verification

GDB had no test anywhere before this, in the wrapper or upstream. Now:

  • TPB is equivalent to GDB on subspaces both can express (an interleaved product), at 576, 59,536 and 1,000,000 determinants — two independent solvers, same Hilbert space, no pinned value needed.
  • Published references: h2o-1em3 -76.23594663, h2o-1em4 -76.24295848, n2-1em4, n2-1em5, up to 16.3M determinants.
  • Sharding invariance: same energy at b 1/2/4/8 and t 1/2, across all six schemes — placement must not change the answer.
  • Driver tests run the scripts as subprocesses, so argument parsing and the defaults are exercised rather than bypassed; the h2o case asserts the same anchor the library test does.
  • Multi-GPU GDB confirmed on 8x H100 with the devices actually busy, not inferred from a matching energy.

28 passed serial, 12 with --run-slow, 13 under mpirun -n 2.

Known gaps

RDMs (do_rdm=1) and savename are untested at b > 1; no multi-node run; DetBasisCommunicator leaks four comms per call upstream, which a long loop would feel. gpu-omp has no GDB kernels at all, so GDB on AMD is CPU-only — documented rather than worked around.

🤖 Generated with Claude Code

…d lists

GDB spans a subspace with an explicit determinant list rather than the Cartesian
product TPB uses, which is what makes it the right solver for a sparse subspace. But
the binding required every rank to pass the WHOLE list, pinning b_comm_size to 1, and
that cascaded into three ceilings:

- b_comm is the only dimension that divides the basis. h_comm is a row stride within
  a block (gdb/mult.h:70) closed by an allreduce (:208) and MakeHelpers ignores it, so
  at b=1 every rank held the entire basis AND the entire excitation lookup however
  many ranks were used. Memory did not scale at all.
- t_comm_size was forced to 1 too: GDB runs one task per basis-ring station and a
  single block has one station.
- GPU GDB was capped at ONE rank. gdb/mult_thrust.h:310-314 throws unless
  h_comm_size == 1, and h = ranks/(t*b), so with b=t=1 the helper dimension took every
  rank. Multi-GPU GDB was unreachable.

So b_comm_size now selects the contract: 1 keeps the previous meaning, >1 means each
rank passes its own shard. What upstream does not check is checked here, collectively
so the verdict is unanimous before any rank raises: shards identical across a b_comm
position (the in-memory path never broadcasts the list, unlike the file overload at
gdb/sbdiag.h:764), globally sorted and disjoint via the neighbour exchange that
load_basis_from_files uses, t <= b, t*b dividing the rank count exactly (upstream's
integer division otherwise yields communicators of unequal size), a non-empty shard
for the schemes that index config[0] unconditionally, and h == 1 on Thrust.
Completeness cannot be checked, so global_dim is returned for the caller to assert on.

All six of the app's placement schemes are exposed as one determinant_distribution
string. The three legacy booleans are deliberately not: they encode a four-way choice
with a priority rule where two are unreachable unless do_redist_alpha_eq is explicitly
zeroed. do_shuffle is dropped from GDB_SBD for the same reason h_comm_size was --
upstream parses it and never reads it, so the attribute could only mislead.

Determinants now cross as an (n, words) uint64 array, built into det_vector with a
single memcpy instead of three copies, and from_strings packs in bulk rather than once
per determinant across the boundary. A nested list still works via forcecast. The
shape is passed as an explicit std::vector<py::ssize_t>: a braced initializer list is
ambiguous under g++ between array_t's ShapeContainer constructor and its copy/move,
which nvc++ and clang accept, so the Linux CPU build would otherwise not compile.

method 2 and 3 are rejected. They are valid for TPB, where they select Lanczos; GDB
has none, and gdb::diag assigns energy only inside its method 0 and 1 branches
(gdb/sbdiag.h:418, :501), so passing 2 returned an uninitialized double with no
diagonalization having run.

Closes half of #22: h_comm_size was already unexposed, and the b_comm_size == 1
restriction it flagged is lifted here using the redistribution APIs the 93ebabec bump
brought in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GDB was untested in this wrapper and, at the pinned upstream commit, upstream's only
GDB test exercises the distribution helpers rather than a diagonalization.

The anchor needs no pinned number: TPB's subspace is one GDB can also express, so
interleaving every (alpha, beta) pair into a full determinant points both solvers at
exactly the same Hilbert space and their energies must agree. On h2o they agree to
1.4e-14, and the full 275^2 interleave reproduces the -76.23594663 published for that
alpha list, which test_reference_energies.py asserts through TPB.

Also covered: the sharded path against a TPB run of the same subspace; the task
dimension becoming usable once b_comm_size allows it; all six placement schemes
agreeing, since placement is a load-balancing decision and must not move the answer;
and each guard with its own case -- non-disjoint shards, a rank count the grid cannot
tile, unknown and misconfigured placement schemes, duplicate determinants, and
Lanczos methods. from_strings is checked elementwise against a per-determinant
from_string loop, and a nested list is checked to still work.

The Fe4S4 case runs in a subprocess: det_vector's row width is fixed per process, and
h2o packs into one 64-bit word where Fe4S4 needs two, so they cannot share one. That
is the constraint gdb_diag reports, and forking is the workaround it prescribes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two drivers, both staying on the in-memory entry point -- determinant text is read in
Python and handed to the binding as an array, never through SBD's file-based overload.

run_gdb_diag.py runs a single diagonalization over an explicit determinant list. It
takes full-determinant files (defaulting to upstream's four Fe4S4 files) or forms the
product basis from an alpha list, which is the subspace TPB would build and so the
cross-check against it. With --b_comm_size it shards: when the number of files is any
multiple of b, each rank reads its own contiguous block of files, which has to be
contiguous rather than strided because shard i must hold a strictly lower range than
shard i+1.

run_gdb_heatbath.py iterates: diagonalize, let SBD expand the subspace from the
resulting wavefunction, diagonalize the larger subspace, repeat. carryover_type 2 and 3
return the parents together with the new candidates, so one round's result is the next
round's subspace and the loop needs nothing extra. It is structured as a cutoff LADDER
because the expansion reaches a self-consistent size for a fixed heatbath_cutoff and
then stops growing -- rounds run at one cutoff until the energy settles or the subspace
stops growing, then the next rung starts, with --max_dim capping the whole run. --seed
picks the starting subspace (determinant files, the Hartree-Fock determinant alone, an
interleaved alpha list, or an arbitrary bitstring file) so one driver produces every
row of a seed comparison.

The README documents what each path does and the constraints that are easy to get
wrong: b_comm_size is the only dimension that divides memory; t <= b because there is
one task per basis-ring station; t*b must divide the rank count; the helper dimension
must be 1 on Thrust, which is why multi-GPU GDB needs b > 1; expansion and carryover
run on the host even in a GPU build, so OMP_NUM_THREADS should stay generous; and
heatbath_truncation prunes parents rather than admitting candidates, so leaving it at 0
is almost always right. It also records that upstream's Fe4S4 "GDB" data is the full
244x244 product of AlphaDets.txt rather than a sparse subspace, which is worth knowing
before reading it as a GDB benchmark, and points at apps/gen_dets for producing
pre-split shard files rather than shipping a second tool for the same job.

Deliberately no timing or hardware figures: the point is which paths exist and what
each costs in memory and constraints, and users should measure on their own systems
rather than inherit ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On an install with more than one backend built, every test that builds objects from
the fixture and then calls a module-level entry point failed:

  TypeError: tpb_diag(): incompatible function arguments.
  Invoked with: ..., <sbd._core_gpu_thrust.TPB_SBD object>, <sbd._core_gpu_thrust.FCIDump object>, ...

The fixture calls get_backend(None), which auto-resolves and PREFERS Thrust when it is
built and a GPU is present. The module-level tpb_diag/gdb_diag then reach
_ensure_initialized(), whose default is 'cpu'. So the FCIDump and config came from one
extension module and the diagonalization was dispatched in another.

Pre-existing and not GDB-specific: test_reference_energies.py fails 2/2 the same way.
It stayed invisible because a CPU-only build has nothing to disagree with, and that is
what CI and a laptop give you. It shows up on any machine where the default build
produced cpu + gpu + gpu-omp, i.e. exactly the hardware the GPU paths are meant for.

Found while confirming the g++ CPU build on a box with both cpu and Thrust modules
present. Pinning the default to whatever the fixture hands out fixes both test files
and keeps SBD_TEST_DEVICE working as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps a reader would hit, now that GDB is more than a footnote.

The Overview presented the bindings as TPB's, with GDB in a trailing sentence. It now
names both methods by the property that decides which you want -- TPB's subspace is a
product of two half-determinant lists, GDB's is the explicit list you pass and so can
be sparse -- and says outright to prefer TPB for a product subspace, since it
represents that case with the two lists rather than every product element and carries
no extra constraints.

The qiskit-addon-sqd section read as though either solver could be plugged in. It
cannot, and not for want of plumbing: the addon's interface is a product subspace by
construction (ci_strings is a (strings_a, strings_b) pair, SCIState.amplitudes an
|a| x |b| matrix), so a sparse determinant list cannot be expressed without padding
back to the full product and discarding the reason to use GDB. Stated plainly, with a
pointer to calling gdb_diag directly.

The API reference mentioned gdb_diag without its data contract. It now says det may be
a whole basis or this rank's shard depending on b_comm_size, lists the constraints a
sharded run carries, and notes they are checked rather than assumed -- then defers to
examples/gdb/README.md for the decomposition, the placement schemes and which returned
values are replicated. Deliberately no copy of that detail here: duplicating it
guarantees the two drift.

Also corrects the savename description, which this branch had made wrong: with the
basis split it is one file per b_comm position, not a single ...000000.bin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stripping the benchmark figures replaced the passage from "Verified on 8x H100..."
onward, but the paragraph before it already made the same point, so the section stated
"multi-GPU GDB requires --b_comm_size" twice in consecutive paragraphs. Merged into
one, keeping the mult_thrust.h reference and the worked example.

The substance was right and is unchanged: GDB has Thrust kernels only, so GPU GDB is
NVIDIA-only and AMD GDB is CPU-only; the Thrust path needs helper == 1, which is why
more than one GPU requires splitting the basis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend bullet listed cpu, gpu and gpu-omp without qualification, which is right
for TPB and misleading for GDB: GDB has Thrust kernels only, so under 'gpu-omp' it
pins a device and then runs on the host. A reader of the landing page alone would have
concluded AMD GDB has GPU support.

Adds the distinction and a pointer, without pulling the GPU detail up from
examples/gdb/README.md. That was the last place in the docs where the claim was
ambiguous; the GDB and shared example READMEs already stated it, and the driver warns
at runtime when --device resolves to gpu-omp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…licated

global_dim() summed local determinant counts over MPI_COMM_WORLD. Ranks sharing a
b_comm position hold identical shards by contract, so with t_comm_size or the helper
dimension above 1 every shard was counted once per replica: Fe4S4's 59,536-determinant
basis reported as 119,072 at --b_comm_size 2 --t_comm_size 2 on four ranks.

Energies were unaffected -- the binding computes its own global_dim over b_comm, which
is what run_gdb_diag reports -- but the driver's dimension column was wrong and
--max_dim compared against the inflated figure, so a ladder would have stopped early.

It hid because every run until now used b == ranks with t = 1, where the world sum and
the b_comm sum coincide. Found by deliberately exercising the b != ranks reshard path.

Fixed by contributing only from the ranks that are one-per-b-position: with the layout
rank = h*(b*t) + t_index*b + b_index those are exactly ranks 0..b_comm_size-1, so the
b_comm sum needs no sub-communicator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch lifts that restriction, and the prose further down already described the
shard contract, but the configuration table was left saying "must be 1 for gdb_diag" --
actively wrong for the branch that changes it. I had edited the surrounding prose and
missed the table.

Replaced with what the three dimensions now are and whose constraint each one is, since
they are different in kind: b_comm_size is free and is the only dimension that divides
memory; t_comm_size <= b_comm_size is upstream's algorithm (one task per basis-ring
station, unchecked there, so gdb_diag rejects it up front); and their product must
divide the rank count, with the derived helper dimension taking the quotient and
required to be 1 on Thrust, which is why more than one GPU needs a split basis. Depth
stays in examples/gdb/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t a limitation

The entry inherited from the reorg branch says multi-rank GPU GDB is impossible because
b_comm_size is pinned to 1. This branch lifts that, so the same symptom now has a
resolution: give every rank to the basis, e.g. -np 4 with b_comm_size 4, leaving a
helper dimension of 1. Also notes that gdb_diag checks it up front rather than letting
the Thrust kernel throw mid-launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The intro and See Also deferred to examples/README.md for backend selection, which no
longer exists. Replaced with what a GDB user needs in place: the --device values, with
'gpu' flagged as the only GPU backend that has GDB kernels; available_backends() and
loaded_backends(); and the 'cpu'/'gpu-omp' interaction, where the CPU module initializes
the shared OpenMP runtime host-only and the offload backend then runs silently on the
host. Links to the TPB README for the bundled test data rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… examples/gdb

The Configuration section had grown three paragraphs on how the GDB communicators
relate, all of which examples/gdb/README.md already covers -- its dimension table, the
ring explanation for t <= b, "only b shards memory", and the Thrust helper restriction.
Replaced with the same shape TPB uses: the rank arithmetic in one line, plus the one API
fact a caller cannot do without (above b_comm_size 1, det is this rank's shard), and a
pointer for the rest. The gdb_diag prose further down is trimmed the same way.

Two stale claims fixed while there. The "Shares ... with TPB_SBD" line listed method,
whose range differs -- GDB has only 0 and 1 -- so method now appears in the GDB table
explicitly, as it does in TPB's. It also listed do_shuffle, which this branch removed
from GDB_SBD because upstream parses it and never reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gdb_diag does not return the wavefunction, which invites the question of how an
iterative driver can work without it. It can because the selection happens inside SBD:
the amplitudes drive it -- weight truncation keeps determinants by |c| and heatbath
scoring is essentially |c_i . H_ij| -- but WeightTruncation and HeatbathExpansion consume
them in C++ and return only the expanded determinant list, which is the next subspace.
Nothing but determinants crosses the Python boundary.

Also says where amplitudes would be needed: Python-side selection, as the TPB
enlarge-subspace driver does, which for GDB would mean reading them back from the
per-shard savename files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/gdb

The top README carried the per-shard file layout, which is GDB detail and
belongs with the rest of it. It also read as a requirement when it is not:
nothing in the normal flow needs the wavefunction -- energy, density and RDMs
come back directly, a heatbath ladder iterates on carryover_det, and GDB is
not wired into qiskit-addon-sqd, whose SCIState would be the usual consumer.

Top README now says amplitudes are not returned, that this rarely matters, and
where to look if you do want them. examples/gdb/README.md gains a short
"Getting the amplitudes, if you want them" section holding the file layout,
and its outputs table stops repeating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…drivers

Three gaps, all found by reading the README as a new user would.

The commands that pass no input flags looked like they ran on no data at all.
They run upstream's Fe4S4 case, because --fcidump and --detfiles default to it;
say so in a new "The default data" section, and show one command with the paths
written out so the --detfiles syntax is copyable.

Add --device gpu examples. GDB's only GPU backend is Thrust, which requires
helper == 1, so multi-GPU GDB cannot omit --b_comm_size -- worth showing next to
the CPU runs rather than only in the GPU section further down.

Document run_gdb_heatbath.py's parameters as tables, grouped seed / ladder /
solver, matching examples/tpb/README.md. Cross-checked against argparse: no
invented flags, every option covered, aliases named.

test/test_gdb_drivers.py is new: the drivers had no test, so argument parsing,
determinant reading, the sharding arithmetic and the default paths could all
break with every library test still green. Runs each driver as a subprocess, so
__main__ and the defaults are exercised rather than bypassed. The h2o case
asserts the same -76.0588897208 that test_gdb_equivalence.py anchors against
TPB, tying driver to solver; the slow case asserts the flagless run really is
Fe4S4 at 59,536 determinants, which is what keeps the README's claim honest.
Both guardrails are covered, t > b especially -- unguarded it segfaults instead
of erroring.

6 passed in 16 s by default, 7 in 37 s with --run-slow. Verified non-vacuous:
perturbing the anchor by 1e-8 fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nput flags differ

The backend block listed all four --device values as if they were alternatives.
For GDB only two are: gpu-omp has no GDB kernels and runs on the host, and auto
can resolve to gpu-omp and do the same. List cpu and gpu, then one sentence on
why the other two are accepted but not GPU paths.

Also state plainly that --fcidump and --detfiles are identical across the two
drivers, and that two input flags are not: the alpha list is --alpha-file in the
heatbath driver but --from-alpha in the diagonalization one, and --seed selects
the subspace source here while there it is an integer RNG seed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ha-file spelling

The two drivers had grown a flag collision and a gratuitous difference.

--seed took a string here (files|hf|from-alpha|strings) and an int in
run_gdb_diag.py, where it is the RNG seed for a random initial vector. Same
flag, incompatible types, so a command could not be moved between the drivers
and `--seed hf` failed confusingly in one of them. Renamed to --subspace-from,
which says what it does. --seed still works and prints a deprecation notice to
stderr; argparse's own deprecated= needs 3.13 and this package supports 3.10, so
the old spelling is detected in argv instead.

The alpha determinant list was --from-alpha in one driver and --alpha-file in
the other, for no reason. Both drivers now accept both spellings.

Tests cover all three paths: either alpha spelling gives the same 576-determinant
energy, and the deprecated --seed still runs while emitting the notice.

9 passed / 1 slow-skipped in the driver file, 26 passed in the serial suite, 13
under mpirun -n 2. README parameter tables re-checked against argparse: no
invented flags, none undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…missed

The branch is unmerged, so nothing depends on the old spelling: --seed is gone
rather than deprecated, along with its argv-sniffing warning and the test for it.
`--seed hf` now exits 2 with "unrecognized arguments".

The previous commit's rename was pattern-by-pattern and missed four lines of the
module docstring plus two messages, so `--help` still advertised --seed while
argparse had moved on. Swept the whole file this time and asserted no mention
survives; the only remaining --seed in examples/gdb is the README line
contrasting it with run_gdb_diag.py's integer RNG seed, which is deliberate.

9 passed with --run-slow, 25 in the serial suite, 13 under mpirun -n 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…subspace-from

--strings-file takes one determinant per line, not a counts JSON, and the
conversion has a step that is easy to miss: qiskit-addon-sqd emits [beta | alpha]
concatenated while GDB wants the bits interleaved. Document both steps with a
snippet built on the driver's own interleave(), and note that a set/sorted()
handles the duplicate rejection and ordering the shard contract expects.

Skipping the interleave is worth spelling out because it does not fail cleanly:
the concatenated strings diagonalize to a plausible number (-66.8043 where the
correct conversion gives -76.0724) and only abort later inside the heatbath
expansion with std::out_of_range. The electron-count check cannot catch it --
permuting bits preserves how many are set -- so the density still sums to 10.

Snippet and command both run as written; the snippet reproduces the same file
byte-for-byte as the conversion that was tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both drivers now compare every determinant's per-spin electron count against the
FCIDUMP's NELEC/MS2 and refuse a mismatch, naming how many determinants are wrong,
the first offender, and the two likely causes: a concatenated [beta | alpha] bit
order where GDB wants them interleaved, or samples that were never postselected.

This is worth a guard because the failure is not self-announcing. Feeding
qiskit-addon-sqd's concatenated strings straight in diagonalizes to -66.8043
where the correct conversion gives -76.0724, then aborts inside the heatbath
expansion with std::out_of_range -- a crash whose message says nothing about bit
order. The occupation density cannot catch it: permuting bits preserves how many
are set, so it still sums to the right electron count.

Cost is not a concern. The popcount runs on the already-packed words via a 256-entry
uint8 table, 37 ms per million determinants measured in isolation, and end to end it
disappears into run variance: a 1M-determinant run took 76.5/76.9/76.6 s without the
check and 77.5/76.6/76.6 s with it. numpy.bitwise_count would be ~9x faster but needs
numpy 2.0 and this package's floor is 1.19, so the table is the portable choice.
--skip-weight-check opts out for a deliberately mixed-sector subspace.

28 passed serial, 12 with --run-slow, 13 under mpirun -n 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mpirun -np 4 -x OMP_NUM_THREADS=8 ...` is Open MPI syntax. On MPICH, Hydra
rejects it outright:

    [mpiexec] match_arg (lib/utils/args.c:166): unrecognized argument x

so that example could not run at all on an MPICH system -- including the h100 box
we validate on. Set the variable in the shell instead, which both implementations
honour for a single-node run, and say why rather than leaving the next person to
rediscover it.

Verified on both: Open MPI 5.0.10 and MPICH 5.0.0 give -76.0588897208 with 144
determinants per rank at --b_comm_size 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cannot shard

Three things a real run hit that the drivers said nothing about.

SBD's Davidson can return without iterating. If the start vector has no coupling to
the rest of the subspace the residual is zero immediately, so it reports one
determinant's diagonal element as the ground state energy: a valid upper bound, but
not the subspace's eigenvalue, and the run exits 0 with a plausible number. The
occupancies give it away -- a one-determinant wavefunction has every occupancy 0 or
1, where a correlated one is fractional -- so both drivers check that and say what
happened. Guarded on dimension > 1, since a single-determinant subspace legitimately
looks that way; the heatbath driver only checks its seed round, because after an
expansion the subspace contains its own excitation neighbours.

--subspace-from hf with --b_comm_size > 1 is a trap: one determinant cannot be
divided, so rank 0 owns it, the other ranks get empty shards, and a single parent
leaves OpenMP nothing to split either -- the first rounds crawl on one core. The
driver now warns, and the README example runs serially.

Also note in the README that the last cutoff dominates a ladder's cost, and that
--max_dim is what makes an unreachable rung a clean stop rather than an
out-of-memory failure mid-round.

Verified: detector true on [1,1,0,0], false on fractional occupancies; a healthy h2o
run emits nothing. 34 passed serial, 13 under mpirun -n 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@garrison

Copy link
Copy Markdown
Member

Related to Qiskit/qiskit-addon-sqd#389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@garrison

Copy link
Copy Markdown
Member

This is well-grounded work and I'd like to get it in rather than let it sit. I had Claude check the upstream claims in the description against vendor/sbd-upstream at 93ebabe and they all hold: the exidx[0].slide starvation (gdb/helper.h:736-761), the Thrust h_size != 1 throw (gdb/mult_thrust.h:310-314), energy assigned only inside the two method branches (gdb/sbdiag.h:418, :501), do_shuffle parsed then never read, and h_comm_size shadowed at gdb/sbdiag.h:206. It also modelled DetBasisCommunicator's splits for several (ranks, b, t) combinations, and the documented shard index rank % b_comm_size is correct in each — that is the load-bearing claim of the whole contract.

The collective discipline looks right: every per-rank check votes through sbd_all_ranks_ok before any rank throws, and the checks that throw directly are functions of the config alone, which every rank passes identically. Declining to expose h_comm_size and do_shuffle, with the upstream lines explaining why, is defensible. The TPB-equals-GDB equivalence test is a stronger anchor than a pinned number.

Three things I'd like addressed, then this looks good to me.

1. bit_length=64 is undefined behavior on the paths this PR newly enables

The drivers default to --bit_length 64 and test_gdb_equivalence.py sets BIT_LENGTH = 64. bitadvance() (framework/bit_manipulation.h:373) computes (((size_t) 1) << bit_length) - 1, so at 64 the shift is UB and the mask collapses to 0 in practice. It is reached from mpi_redistribution and mpi_sort_bitarray, i.e. from the new count and count-sorted schemes. This is the hazard established in #12.

Claude's reading of why the suite still passes is worth repeating: _shard() in the test reproduces get_mpi_range (framework/mpi_utility.h:22) exactly, so redistribution's fast path (caop/basic/basis.h:54-75) returns early on every rank and bitadvance never executes. So count is nominally covered but its redistribution code never runs. A caller who shards unevenly — the realistic case, and the one the count scheme exists for — would reach it.

Could the drivers default to 62, and could one test use a deliberately uneven split so the fast path is bypassed? 62 rather than 63 because of the interaction with check_spin_weights below, and rather than a smaller value because it keeps h2o's 48 interleaved bits in a single word — det_vector::init_elem_size fixes ceil(2*norb / bit_length) process-wide, so a value implying two words would collide with test_reference_energies.py and test_sqd_integration.py when they share a process. This came from tracing the call chain rather than from an observed failure, so treat it as a thing to confirm rather than a reproduction.

2. check_spin_weights skips silently on any bit_length it cannot handle

check_spin_weights (run_gdb_diag.py:117, run_gdb_heatbath.py:236) returns early unless bit_length == 64, printing nothing. --bit_length is user-settable and examples/gdb/README.md:323 demonstrates --bit_length 20, so that path drops the validation today — no default change needed to reach it.

That seems worth failing loudly over, because the function's own rationale is that this error class is invisible downstream: as the description notes, the occupation density cannot catch a permuted bit order since permuting preserves how many bits are set. A check premised on "nothing else will catch this" should not opt itself out quietly.

The masks are also more capable than the guard suggests. 0x5555…/0xAAAA… are correct whenever a word boundary preserves the alpha/beta alternation, which holds for any even bit_length — 20 and 32 included — and breaks only for odd ones. So the condition looks like it wants to be bit_length % 2 rather than != 64, and the odd case should raise or warn rather than return. Worth noting that 63 is an odd value, which is why I suggested 62 above: pairing a 63 default with this guard as written would turn a silent skip into a silent miscomputation.

3. The MPI tests do not follow the _standalone / _mpi split

test_gdb_equivalence.py:234, :350, :372, :400, :424 and :443 are single @pytest.mark.mpi functions. pytest-mpi filters on that marker in opposite directions, so these never run under tox -e py — including test_gdb_placement_does_not_change_the_energy, where four of the six schemes are meaningful at one rank. The convention elsewhere in the suite is one shared private body wrapped in two thin functions (test_sqd_integration.py:200-210).

Smaller things

  • README.md:263 still shows gdb_diag without the three new keyword arguments, and the Returns list below it omits local_dim, global_dim and determinant_distribution, which the docstring and the release note both document.
  • The savename change from one file to one per b_comm position is user-visible and currently documented only in the docstring. It seems to belong in the release note's upgrade section next to the method rejection.
  • MPI_UNSIGNED_LONG at python/bindings.cpp:908 and :910 is correct on LP64 but is the only place size_t is sent as that type; everything else in the file uses MPI_UNSIGNED_LONG_LONG.

Neither Claude nor I built or ran this branch, so the reported 28 / 12 / 13 counts are unverified on our side.


This review was drafted by Claude Opus 5 under my guidance.

Sophia Wen (hfwen0502) and others added 3 commits October 8, 2026 17:35
# Conflicts:
#	README.md
bit_length:
- The GDB drivers default to 62 and refuse anything that is not even and in
  [2, 62]. 64 overflows the shift in SBD's bitadvance(), which the count and
  count-sorted schemes reach through mpi_redistribution on uneven shards. Odd
  sizes break SBD's alpha/beta conversion once a determinant spans two words:
  gdb::getHalfDets assumes an even size in every build, and DetFromAlphaBeta
  does under SBD_TRADMODE. The heatbath expansion then crashes, or returns a
  wrong energy (h2o at 25, 31, 33 and 47; all backends, checked on 8x H100).
- test_gdb_equivalence uses 62, and a new MPI test hands the count schemes
  deliberately uneven shards, asserting they come back balanced, so
  redistribution's real path runs instead of its fast path.

check_spin_weights:
- The alpha/beta masks are per word, so the check works at every bit_length
  instead of returning silently unless it is 64. Driver tests cover refusal at
  20 and 30, acceptance across several words, and rejection of 64 and 31.

Tests:
- The sharded-basis, placement and rank-tiling tests follow the
  _standalone / _mpi split, so they also run under tox -e py.
- On the Thrust backend, the two tests that put ranks on the helper dimension
  now check that it is refused, as documented, instead of expecting an energy.

Smaller:
- README gdb_diag shows the placement kwargs and the three new result keys.
- The release note's upgrade section covers savename writing one file per
  b_comm position.
- The neighbour check sends its size_t buffers as SBD_MPI_SIZE_T.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hfwen0502

Copy link
Copy Markdown
Member Author

Thanks for the careful review. All three points are addressed in edfc1aa, along with the smaller ones. Point 1 is confirmed, and point 2 turned out to go deeper than the spin check.

1. bit_length=64

Confirmed. bitadvance() is reached on the path you traced. With deliberately uneven shards (384/192, rebalanced to 288/288), mpi_redistribution runs, and the last rank's exclusive upper bound ("last determinant + 1") collapses to all zeros at 64. On clang/arm64 that happens not to change the placement for h2o, so the energy was still right. It is undefined behavior on a real path all the same.

  • Both drivers default to 62, and test_gdb_equivalence.py uses 62.
  • New test_gdb_count_schemes_rebalance_uneven_shards (MPI) hands count and count-sorted a lopsided split, then asserts the counts come back balanced. That proves the slow path ran. It also asserts the energy matches TPB.

2. check_spin_weights, and odd bit_length in SBD itself

The masks are now per word: word j starts at global bit j * bit_length, so its alpha mask is 0x5555… or 0xAAAA… depending on the parity of that start. The check works at every bit_length and never skips. I verified it against direct string counts at 20, 31, 62, 63 and 64, up to 4 words, in both drivers.

While testing odd sizes I found that SBD's GDB heatbath expansion breaks whenever bit_length is odd and a determinant spans two or more words. Two upstream functions assume an even size:

  • gdb::getHalfDets (chemistry/gdb/helper.h:160), compiled in every build: half_bl = bit_length / 2, plus even-position masks applied per word.
  • DetFromAlphaBeta under SBD_TRADMODE (chemistry/basic/determinants.h:20), and the copy in omp_offload.h: half = bit_length / 2. The #else variant is correct.

Python ports of both mis-convert 100% of h2o determinants at 25, 31 and 33, partly at 47, and none at even sizes or at 63, where h2o fits in one word. Observed with the h2o heatbath:

bit_length SBD_TRADMODE on (our CPU/gpu-omp build) SBD_TRADMODE off
25, 31, 33 crash crash
47 crash wrong energy, exit 0 (−76.0714804798 vs −76.0723973374)
20, 62 ok ok
24, 30, 63 ok not run

On 8×H100 it also fails on gpu and gpu-omp (std::bad_alloc, heap corruption). Plain gdb_diag stays correct at odd sizes, apparently because it only uses the half-determinants as internally consistent keys. So the drivers now require an even bit_length in [2, 62] and refuse anything else up front. gdb_diag itself still passes the value through.

3. _standalone / _mpi

Split for the sharded-basis, placement (all six schemes) and rank-tiling tests, which now run under tox -e py too. Three stay MPI-only:

  • test_gdb_task_dimension_works_once_the_ring_has_stations and test_gdb_rejects_shards_that_are_not_disjoint need 4 and 2 ranks, and already skip below that.
  • test_gdb_under_mpi_matches_tpb_on_the_same_subspace would duplicate test_gdb_matches_tpb_on_the_same_subspace at one rank; its docstring says so.

Running on GPUs also showed that two of these tests could never pass on Thrust: they put ranks on the helper dimension, which the binding refuses there by design. On Thrust they now assert that refusal.

Smaller things

  • README gdb_diag shows the three keyword arguments and the local_dim / global_dim / determinant_distribution result keys.
  • The release note has an upgrade entry for savename writing one file per b_comm position. Before this PR, b_comm_size had to be 1, so it was always a single {savename}000000.bin.
  • bindings.cpp:908/910 now send SBD_MPI_SIZE_T rather than MPI_UNSIGNED_LONG_LONG. The buffers are std::vector<size_t>, and upstream already defines that macro from SIZE_MAX. The other sites in the file send variables declared unsigned long long, which is why they use the _LONG_LONG type.

Verification

Built and run this time:

  • Laptop (CPU): 47 single-process tests pass; the MPI suite passes at 2, 3 and 4 ranks.
  • 8×H100, all three backends:
    • 47 single-process tests pass on each.
    • The MPI suite passes at 2, 4 and 8 ranks.
    • h2o_1em4 as a 2,380,849-determinant product basis: GDB on gpu under equal-bra-a, count, count-sorted and grid-cyclic, plus cpu and gpu-omp, all give −76.2429584823, equal to TPB on the same subspace at the same tolerance. All 8 GPUs were busy during the Thrust run.
    • Fe4S4, 72-bit determinants packed into two 62-bit words:
      • Upstream's bundled files (59,536 dets) give −326.6982518821 under all four placements on 8 GPUs.
      • A 1,000,000-determinant product basis gives −326.6135721292 from GDB on gpu, cpu and gpu-omp, the same as TPB.
      • The heatbath ladder on 4 GPUs grows 59,536 → 636,338 → 806,932 determinants, and the energy decreases every round.

This reply was drafted by Claude Opus 5.5 under my guidance.

@garrison
Jim Garrison (garrison) merged commit de585ac into main Oct 9, 2026
16 checks passed
@garrison
Jim Garrison (garrison) deleted the gdb-sharding branch October 9, 2026 19:14
Sophia Wen (hfwen0502) added a commit that referenced this pull request Oct 9, 2026
…notes

- carryover-type-default-zero: a non-zero carryover_type now hands SBD's
  selection to the SQD loop, so only the default is a pure speedup.
- fix-fabricated-amplitudes: with carryover_type = 1 no wavefunction is
  requested and sci_state is None.
- remove-h-comm-size: name GDB's b_comm_size and t_comm_size too.
- fix-amplitude-labels: canonical order is ascending integer order by
  construction (#31), not by coincidence.
- thrust-mpi-build-options: SBD_THRUST_SAFE_MPI_ALLREDUCE may not be enough on
  its own; it leaves point-to-point device transfers unstaged.
- sqd-amplitudes-change-results: drop what repeats fix-fabricated-amplitudes.
- macos-support: match INSTALL.md (conda llvm-openmp first, Homebrew libomp as
  the fallback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jim Garrison (garrison) added a commit that referenced this pull request Oct 9, 2026
* Prepare 1.7.0 release

* Add mergify configuration

* Move release notes to release directory

* Move remaining note

* Tweak note text

* Release notes: correct what #49 and #46 made stale, and tighten four notes

- carryover-type-default-zero: a non-zero carryover_type now hands SBD's
  selection to the SQD loop, so only the default is a pure speedup.
- fix-fabricated-amplitudes: with carryover_type = 1 no wavefunction is
  requested and sci_state is None.
- remove-h-comm-size: name GDB's b_comm_size and t_comm_size too.
- fix-amplitude-labels: canonical order is ascending integer order by
  construction (#31), not by coincidence.
- thrust-mpi-build-options: SBD_THRUST_SAFE_MPI_ALLREDUCE may not be enough on
  its own; it leaves point-to-point device transfers unstaged.
- sqd-amplitudes-change-results: drop what repeats fix-fabricated-amplitudes.
- macos-support: match INSTALL.md (conda llvm-openmp first, Homebrew libomp as
  the fallback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only deploy docs from the stable branch

---------

Co-authored-by: Sophia Wen <hfwen@us.ibm.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Jim Garrison (garrison) added a commit that referenced this pull request Oct 9, 2026
…der (#55)

* Document how to retrieve TPB amplitudes, and the bitstring packing order

Two things a user has to discover by reading upstream C++ or experimenting.

TPB has two wavefunction output paths that write different formats, and
nothing said so. `savename` -- the one named in `tpb_diag`'s signature --
goes to `SaveWavefunction`, which writes one file per b_comm position
holding only that rank's block, behind a three-`size_t` header and the
block's determinant words. `sbd_data.dump_matrix_form_wf` goes to
`SaveMatrixFormWF`, which gathers the full adet x bdet subspace onto one
rank and writes a bare row-major float64 array with no header at all, or,
for a `.dat`/`.txt` path, a text table labelling each amplitude with its
own alpha and beta bitstring.

The second is what almost every caller wants, and is what this package's
own SQD integration already uses (`sbd_solver.py` sets it and reads the
result with a bare `np.fromfile`). It appeared only as a one-line pybind
docstring and a CLI flag in `run_sbd_diag.py`, absent from the README's
TPB section and from the `tpb_diag` docstring, both of which mention
`savename` alone -- so following the signature led to parsing the wrong
layout. Document both, side by side, and note that the text form is
written with default stream formatting and so carries ~6 significant
digits rather than full float64.

`from_string` and `makestring` packed words with no stated convention.
Bitstrings are packed from the right: the trailing `bit_length` characters
become word 0, so the leading characters of a multi-word string land in
the last word, not the first. Easy to get backwards, and a wrong guess
yields a valid-looking but wrong subspace rather than an error.

GDB needed no changes here: #46 documented its amplitude retrieval and
file layout in examples/gdb/README.md.

Assisted-by: Claude Opus 5

* Fit the amplitude and packing docs to the API surface #53 defined

#53 curated the autodoc list and added field tables, which changes where
this branch's documentation belongs.

The packing convention moves from `from_string` to `from_strings`. #53
documents the bulk form and deliberately leaves the singular one out, so
the convention was written on a function the rendered docs never show --
and `from_strings`, the form users are steered to, had no packing
documentation of its own. `from_string` and `makestring` now point at it
instead of restating it. The examples are shown as the `uint64` ndarray
`from_strings` actually returns rather than as nested lists.

`dump_matrix_form_wf` now appears in #53's `TPB_SBD` field table as well
as the README's, so the README row defers to the format section instead of
describing the field a second time.

Also corrects a claim this branch made about `sbd_solver.py`: since #49 the
wavefunction write is skipped when SBD selects the carryover itself
(`carryover_type` 1 against an addon that accepts it), because the next
subspace then comes back in `carryover_adet`/`carryover_bdet` and the
amplitudes never reach Python. Saying it "uses" the dump without that
qualification implied the write always happens.

`tox -e docs` passes with no warnings, and the packing convention renders
on the `from_strings` entry.

Assisted-by: Claude Opus 5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants