Coulomb backends: exact, density-fitted, streamed, screened¤
The two-electron Coulomb (and exact-exchange) term is the engine's cost center. dftax
offers a ladder of backends with the same energy but different memory/compute
trade-offs. Each backend is a value passed to the builder as coulomb=; invalid
combinations raise at the factory.
Density fitting (RI, the default)¤
from dftax import KS, Molecule, df, scf
from dftax.energy.xc import PBE
water = Molecule.from_xyz("O 0 0 0; H 0.7586 0 0.5043; H 0.7586 0 -0.5043", "sto-3g")
scf(KS(water, PBE())) # same as coulomb=df()
scf(KS(water, PBE(), coulomb=df())) # def2-universal-jkfit, chunk="auto"
Resolution-of-identity reduces the 4-center integral to 2- and 3-center pieces against an
auxiliary basis (functions up to i, so transition metals work): O(N³) compute,
O(N²·N_aux) memory for the materialized 3-center tensor, and the chunk="auto" policy
switches to the streamed contraction when that tensor would exceed a device-aware
memory budget (a fraction of the GPU pool; a fixed fallback on CPU).
The RI error against the exact path is sub-mHa with the JK-fitting set.
On the materialized paths (single-device and mesh-sharded alike) the auxiliary
basis is built in spherical harmonics (fewer functions, a tighter fit, and
better cross-backend force reproducibility); only the streamed backend keeps
the cartesian span, since it contracts cartesian auxiliary elements on the
fly. df(spherical=False) pins a common fit space when comparing against the
streamed backend, and df(screen=...) without a chunk runs a shell-pair Schwarz
compact gather on the materialized build (screened pairs stay exactly zero),
so extended systems build O(N) rather than O(N²) shell pairs.
Exact 4-center ERIs¤
from dftax import exact
scf(KS(water, PBE(), coulomb=exact())) # builds the (nao⁴) ERI tensor
Exact (μν|λσ) via McMurchie-Davidson, using the 8-fold permutational symmetry. O(N⁴)
memory: small systems and reference comparisons (it is also the fallback when building
from a raw System, which has no element symbols to resolve an auxiliary basis name).
Two variants trade compute for memory:
exact(screen=1e-10) # Cauchy-Schwarz-screened materialized ERI tensor
exact(stream=True) # J/K contracted on the fly, O(N²) memory, no ERI tensor
Streaming + screening (large systems)¤
For memory-light runs, stream the RI-J contraction over auxiliary chunks (never forming the nao²×naux tensor) and Schwarz-screen negligible bra pairs; the XC integral streams over grid chunks the same way via the grid spec:
from dftax import KS, becke, df, scf
ks = KS(water, PBE(),
coulomb=df("def2-universal-jkfit", chunk=64, screen=1e-10),
grid=becke(chunk=20_000))
scf(ks).e_tot
df(..., chunk=...) sets the auxiliary streaming chunk; screen= drops bra pairs
whose self-Schwarz factor is below the relative cutoff (O(N) survivors for extended
systems; it requires chunk). becke(chunk=...) (or points(coords, weights,
chunk=...) for an explicit grid) streams the XC integral over grid chunks of that
many points, O(chunk·nao) memory. Hybrids stream RI-K the same way.
Hybrids and exact exchange¤
PBE0 / B3LYP add a fraction of exact exchange K. On the exact path it is contracted
on the fly (exchange_k_4c); under density fitting it uses streamed RI-K. The L ≥ 2 exact
path is compile- and memory-bounded by scanning the bra primitive pair in the ERI kernel
(so cc-pVDZ compiles in seconds rather than OOMing).
Range-separated hybrids (CAMB3LYP, WB97X, WB97XV) split the exchange into
short- and long-range parts via erf(ω·r₁₂)/r₁₂ integrals and run on every DF
backend: the materialized path holds the attenuated tensors, the streamed path
recomputes attenuated 3-center elements on the fly (only the small attenuated
metric is stored), and the mesh-sharded path runs the long-range exchange as
slab-wise all-to-all rounds. exact(stream=True) is the one backend without an
attenuated variant. WB97XV additionally carries VV10 nonlocal correlation,
which needs the materialized XC grid (no becke(chunk=...) or mesh=).
Choosing a backend¤
| System size | Backend |
|---|---|
| anything, by default | df() (auto materialize/stream) |
| small / validation | exact() |
| small, memory-tight | exact(stream=True) or exact(screen=1e-10) |
| large, hand-tuned | df(chunk=64, screen=1e-10) + becke(chunk=20_000) |
Multi-GPU: mesh=¤
KS(..., mesh=mesh()) shards the calculation across a 1-D device mesh (all
local devices by default; pass mesh(devices=[...]) to choose). The XC
quadrature is sharded over grid points, and the density-fitted backend builds
and holds its 3-center tensor in per-device aux slabs (no device ever
materializes more than naux/ndev of it), with hybrid exact exchange computed
slab-wise. The dense nao² matrices (S, hcore, P, Fock) stay replicated, and
every collective differentiates, so scf, minimize, and property workflows
run unchanged. A one-device mesh is a no-op.
The streamed df(chunk=...) backend shards too, and it is the one that reaches
protein scale: the materialized slabs still cost nao²·naux/ndev per device
(15 TiB for insulin at triple zeta), while the streamed path holds no tensor at
all and each device takes its own contiguous slice of the auxiliary range. RI-J,
RI-K and range-separated exchange all shard, dense or Schwarz-screened, so the
combination covers the whole functional range with one exception: WB97XV's
VV10 nonlocal correlation, whose double-grid pair quadrature is nonlocal across
shards and cannot be evaluated shard by shard at all. That one raises at build
time.
Across nodes¤
The same mesh spans several nodes once the processes have joined a group.
JAX is multi-controller, so the launcher starts the same program once per node
and distributed() does the rest:
from dftax import KS, df, distributed, mesh, scf
distributed() # reads the SLURM environment; no-op on one node
ks = KS(mol, PBE0(), coulomb=df("def2-universal-jkfit"), mesh=mesh())
res = scf(ks) # every process runs this and gets the same answer
Call it before any other JAX work (importing dftax starts no backend, so import
order does not matter). Each process builds only the auxiliary slabs its own
GPUs hold, which is what makes the capacity scale with the whole allocation
rather than with one node. Every process must run the same sequence of
collectives, so use is_coordinator() to guard printing and file writes, never
to skip a build; barrier() synchronizes the group where they stop being
symmetric. scripts/gpu/distributed.sbatch is a working two-node template, and
scripts/gpu/validate_distributed.py checks a distributed solve against the
single-device one in the same run.