Skip to content

Par gradient - #16

Open
ZuseZ4 wants to merge 4 commits into
mainfrom
par-gradient
Open

Par gradient#16
ZuseZ4 wants to merge 4 commits into
mainfrom
par-gradient

Conversation

@ZuseZ4

@ZuseZ4 ZuseZ4 commented Aug 2, 2026

Copy link
Copy Markdown
Member

Your 2k line PR was too hard to review, so I split it up a little

mindeye33 and others added 4 commits August 2, 2026 15:59
src/par.rs is a threaded counterpart to the dscf.rs gradient entry points:
dS_par, dHcore_par, dR_par and danalytical_par, each accumulating the
basis-parameter adjoint over rayon work-stealing. The serial path is
deliberately untouched -- danalyticalf stays byte-for-byte what the
finite-difference validation covers, so "parallel matches serial" remains a
statement about two independent implementations.

Enzyme reverses turn out to be reentrant. Nothing guaranteed that -- the
generated bodies carry their own tape -- and if they had not been, the answer
was MPI processes rather than shared memory, a different design and not a
tuning knob. Per task the closure carries its own scratch and its own clones
of atm/bas/env: #[autodiff_reverse] demands &mut on arguments the loop only
reads, and those arrays are kilobytes, so cloning beats reworking every
signature in the call chain. rayon's fold runs its init closure once per work
chunk, so the clones are amortized rather than paid per pair.

Two design points, both measured rather than assumed
(python/pyscf_comp/bench_par_loops.py measures both):

1. Distribute over (i, j) shell PAIRS, not the bra index. Splitting the 2e
   loop over i alone leaves one shell holding 7-11% of the total work, so
   makespan >= that item and block, stride and work-stealing all collapse to
   the same efficiency past ~9-14 threads. Over pairs the largest item is
   0.4-1.4%, lifting the ceiling to ~70-250. The k/l bounds depend only on i
   and j, so a canonical pair is self-contained and the 8-fold permutational
   reduction is untouched.

2. Work-stealing, not a static schedule. At pair granularity on C4H10/def2-svp
   with 64 threads the model gives block 0.40, stride 0.71, work-stealing
   1.00. Pairs are emitted largest-first so the expensive ones go out early
   and the cheap ones are left as filler.

pair_1e_in generalizes the 1e shell-pair loop over the Enzyme reverse, so dT
and dV get the same treatment dS has. It takes the reverse as a monomorphized
generic rather than a fn pointer -- an indirect call into an Enzyme-generated
body type-checks and then misbehaves under fat LTO. danalytical_par assembles
dHcore + dR - 0.5 dS and builds ONE rayon pool for all four loops instead of
one per loop; nthreads = 0 skips the pool build and uses the global pool, so
RAYON_NUM_THREADS applies. That is the preferred path for repeated calls: at
~10 ms of work the 1e loop peaks near 5-7x and then gets slower past 16
threads, because building a ThreadPool per call dominates.

Every parameter is a slice, matching the convention main adopted in 0d46255 --
a Fn bound needs the exact type and deref coercion does not apply, so
F: Fn(&mut Vec<f64>, ...) would not have matched dovlp/dkin/dnuc. leak_vec
becomes pub(crate) so par.rs can return buffers over the same C ABI and free_c
contract as p2c.rs; the four #[no_mangle] pub extern "C" entry points are the
FFI boundary _bindings.py calls. rayon 1.11 was already in the tree as a faer
dependency, so --offline builds are unaffected.

Measured on one exclusive node, 96 cores, against the serial dscf entry
points:

  dR 2e   C2H6/def2-tzvp   27.0s -> 0.68s   39.7x @ 64 threads (eff 0.62)
                                            15.4x @ 16 (eff 0.96)
          H2O/def2-qzvp    34.6s -> 1.28s   27.0x @ 64 (eff 0.42)
          CH4/def2-svp     0.60s -> 0.026s  23.3x @ 64 (eff 0.36)

All parallel results match serial to 2e-14 .. 2.5e-12, which is the round-off
from summing the per-task partials in a different order. Measured efficiency
at 64 threads falls short of the model's 1.00, which is expected: the model
costs quartets as prod(d) * prod(nprim) * nroots and knows nothing about pool
construction, memory bandwidth, or Schwarz screening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bench_grad_breakdown on CH4/def2-svp, with dS and dR threaded and everything
else serial:

    dR 2e pair loop           0.784s  84.3%   dR_par
    getF = int2e_fock + PFP   0.130s  14.0%   serial
    dHcore (dT+dV)            0.012s   1.3%   serial
    dS pair loop              0.003s   0.4%   dS_par
    serial fraction 0.153 -> capped at 6.5x at infinite threads

So the 2e reverse scaling 40x bought a gradient that could never beat 6.5x.
getF was invisible in that work because it is a primal libcint build, not an
Enzyme reverse, and the parallelization effort had been aimed at the autodiff
loops. H2O/def2-qzvp is the same shape: getF 5.6s against dR 47s.

fock2e_par_in threads scf::integral2e_fock over shell pairs. Two things it
has to get right:

- Private accumulators. The Coulomb term writes G[mui,nuj], disjoint across
  j, but the exchange term writes G[mui,laml] with l over every shell, so two
  (i,j) tasks sharing an i collide. Each task folds into its own nao^2 buffer
  and the reduction sums them.
- One shared CINTOpt. Every (*opt). access in the cint2e evaluation path
  loads into a local and none store back, which is the contract that lets
  pyscf hand one optimizer to every OpenMP thread. Per-task optimizers would
  be the conservative choice but its tables are O(nbas^2), rebuilt once per
  work chunk, and at 64 threads that costs more memory than the gradient.
  The wrapper needs a get() method rather than a public field: closures
  capture disjoint fields, so `shared.0` would capture the bare *mut CINTOpt
  and drop the Sync wrapper on the floor.

danalytical_par now runs getF inside the same pool as the three adjoint loops,
so one pool covers the whole gradient. Left serial: the O(nao^3) P F P
matmults and the nbas^2 1e primals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…are absent

librint.dscf gains dS_par, dR_par, dHcore_par and danalytical_par, with
argtypes for dS_par_c / dR_par_c / dHcore_par_c / danalytical_par_c in
_bindings.py. Until now the Python side went through the serial
danalytical_c only, so the 40x on dR was a number about a loop rather than
about a gradient.

The symbols are bound defensively rather than eagerly. Any .so built before
src/par.rs existed has none of them, and resolving them at import time turned
that into an `import librint` failure for everyone, including callers who only
want the serial path. HAS_PAR records their absence so dscf can raise
something actionable and the test suite can skip. (This does not rescue the
.so committed in python/librint/, which is missing free_c and every other FFI
entry point and so cannot satisfy `import librint` on main either -- that one
needs a rebuild, which is not this PR's business.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
python/tests/test_par_equiv.py compares every threaded term against its serial
counterpart at 1..64 threads. The criterion is not bitwise -- work stealing
reassociates the sum -- it is that the error stays FLAT in thread count, which
is what separates round-off from a race. Measured 1e-16 (dHcore) to 5e-12
(assembled, where large terms cancel), constant across T, and repeat runs at
fixed T come out bitwise identical.

The thread sweep runs inside a single test per system rather than as a
parametrize over thread counts, because the SCF dominates the runtime and
parametrizing would pay for it once per count. The five basis sets that cost
minutes (def2-tzvp and up) are marked `slow`, so `pytest -m "not slow"` is an
8-second loop while the full run still covers the f-shell, g-shell and
general-contraction paths those systems exist to reach; pyproject declares the
marker.

test_gradient_fd.py gains a threaded case. It is redundant on paper --
test_par_equiv ties par to serial and the existing test ties serial to finite
differences -- but the transitive argument breaks silently if either link is
weakened, and a direct check does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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