1- """
2- Sparse Fock matrix build with integral screening.
3-
4- Demonstrates how Schwarz screening reduces the Fock build from O(N⁴) to
5- effectively O(N²) for large molecules. The sparsity pattern is stored
6- as a CSR matrix, and SpMM handles the screened contraction.
1+ """Sparse Fock matrix build with integral screening.
2+
3+ Three paths, side by side:
4+
5+ 1. **v0.1.x unfused** — `schwarz_bounds` → `screen_quartets` → mask-apply
6+ → `from_dense` → `spmm`. What the library shipped before v0.4.0.
7+ Four host passes over an (n, n) mask + a separate CSR build + a
8+ separate SpMM dispatch.
9+ 2. **v0.4.0 fused** — `screened_spmm(A, diag_integrals, B, threshold)`.
10+ One NKI kernel on the NKI backend; explicit mask + matmul on CPU.
11+ Same numeric result, fewer HBM round-trips, no mask tensor on HBM.
12+ 3. **Full Fock build** — the coulomb J from path 2 contracted against
13+ MO coefficients via trnblas: `F_MO = C.T @ J @ C`. Demonstrates the
14+ suite composition — trnsparse hands off to trnblas's GEMM once the
15+ sparse step is done.
16+
17+ Schwarz bounds here are synthetic (Gaussian-distance decay) — realistic
18+ enough to show non-trivial sparsity. For real AO integrals from a
19+ molecule, see `examples/pyscf_bridge.py`.
720
821Usage:
922 python examples/sparse_fock.py --demo
10- python examples/sparse_fock.py --nbasis 200
23+ python examples/sparse_fock.py --nbasis 200 --threshold 1e-8
1124"""
1225
26+ from __future__ import annotations
27+
1328import argparse
1429import time
1530
1833import trnsparse
1934
2035
21- def main ():
22- parser = argparse .ArgumentParser ()
23- parser .add_argument ("--demo" , action = "store_true" )
36+ def _synthetic_schwarz_system (n : int , seed : int = 42 ) -> torch .Tensor :
37+ """Return synthetic diagonal integrals `(μμ|μμ)` for a 1-D molecular chain.
38+
39+ Chemistry convention: the Schwarz bound for `(μν|μν)` factors as
40+ `Q[μ] * Q[ν]` where `Q[i] = sqrt((ii|ii))`. This demo generates
41+ per-shell magnitudes that span several orders of magnitude so the
42+ outer-product bound `Q[i] * Q[j]` produces non-trivial sparsity at
43+ a realistic threshold.
44+ """
45+ torch .manual_seed (seed )
46+ # Shells arranged in a 1-D chain; Gaussian-like decay in magnitude
47+ # from a central "heavy" region so there's a tail of small Q values.
48+ idx = torch .arange (n , dtype = torch .float32 )
49+ center = n / 2.0
50+ diag_integrals = torch .exp (- ((idx - center ) ** 2 ) / (2.0 * (n / 6.0 ) ** 2 )) + 0.01
51+ return diag_integrals
52+
53+
54+ def _unfused_path (
55+ integrals_dense : torch .Tensor , diag_integrals : torch .Tensor , P : torch .Tensor , threshold : float
56+ ):
57+ """Path 1: explicit Schwarz bound + mask + from_dense + spmm. v0.1.x flow."""
58+ import math
59+
60+ t0 = time .perf_counter ()
61+ Q = trnsparse .schwarz_bounds (diag_integrals ) # (n,)
62+ pair_bound = Q .unsqueeze (- 1 ) * Q .unsqueeze (0 ) # (n, n)
63+ mask = pair_bound > math .sqrt (threshold )
64+ integrals_masked = integrals_dense * mask .to (integrals_dense .dtype )
65+ integrals_sparse = trnsparse .from_dense (integrals_masked )
66+ J = trnsparse .spmm (integrals_sparse , P )
67+ return J , time .perf_counter () - t0
68+
69+
70+ def _fused_path (
71+ integrals_dense : torch .Tensor , diag_integrals : torch .Tensor , P : torch .Tensor , threshold : float
72+ ):
73+ """Path 2: v0.4.0 fused screened_spmm."""
74+ t0 = time .perf_counter ()
75+ J = trnsparse .screened_spmm (integrals_dense , diag_integrals , P , threshold = threshold )
76+ return J , time .perf_counter () - t0
77+
78+
79+ def _full_fock_build (J : torch .Tensor , C : torch .Tensor ):
80+ """Path 3: transform the coulomb J into the MO basis via trnblas.
81+
82+ F_MO = C.T @ J @ C — two GEMMs. Falls back to torch.matmul if
83+ trnblas isn't importable (it's an optional suite dep; pure-trnsparse
84+ users don't need it).
85+ """
86+ t0 = time .perf_counter ()
87+ try :
88+ import trnblas
89+
90+ Jt = trnblas .gemm (1.0 , C , J , transA = True ) # C.T @ J
91+ F_MO = trnblas .gemm (1.0 , Jt , C ) # (C.T @ J) @ C
92+ backend = "trnblas"
93+ except ImportError :
94+ F_MO = C .T @ J @ C
95+ backend = "torch.matmul (trnblas not installed)"
96+ return F_MO , time .perf_counter () - t0 , backend
97+
98+
99+ def main () -> None :
100+ parser = argparse .ArgumentParser (description = __doc__ )
101+ parser .add_argument ("--demo" , action = "store_true" , help = "run a small demo" )
24102 parser .add_argument ("--nbasis" , type = int , default = 50 )
25- parser .add_argument ("--threshold" , type = float , default = 1e-10 )
103+ parser .add_argument ("--threshold" , type = float , default = 1e-4 )
26104 args = parser .parse_args ()
27105
28106 if args .demo :
@@ -33,47 +111,53 @@ def main():
33111 print (f" Basis functions: { n } " )
34112 print (f" Threshold: { args .threshold :.0e} " )
35113
36- torch .manual_seed (42 )
114+ diag_integrals = _synthetic_schwarz_system (n )
115+ Q = trnsparse .schwarz_bounds (diag_integrals ) # 1-D Schwarz bounds
37116
38- # Simulate Schwarz bounds (decay with distance for realistic sparsity)
39- positions = torch . rand ( n , 3 ) * 10.0 # Random 3D positions
40- distances = torch .cdist ( positions , positions )
41- Q = torch .exp ( - 0.5 * distances ) # Gaussian decay
117+ # Unscreened ERI slice — random, scaled by outer-product Schwarz
118+ # (chemistry-realistic: integrals tracking the bound).
119+ torch .manual_seed ( 0 )
120+ integrals_dense = torch .randn ( n , n ) * ( Q . unsqueeze ( - 1 ) * Q . unsqueeze ( 0 )) * 0.01
42121
43- # Screen
44- stats = trnsparse .sparsity_stats (Q , args .threshold )
45- print ("\n Sparsity statistics:" )
46- print (f" Total shell pairs: { stats ['total_pairs' ]} " )
47- print (f" Significant pairs: { stats ['significant_pairs' ]} " )
48- print (f" Pair sparsity: { stats ['pair_sparsity' ]:.1%} " )
49- print (f" Quartet sparsity (lower): { stats ['quartet_sparsity_lower' ]:.1%} " )
122+ # Density matrix — random SPD.
123+ M = torch .randn (n , n ) * 0.1
124+ P = M @ M .T
50125
51- # Build sparse integral matrix (simulated)
52- mask = trnsparse .screen_quartets (Q , args .threshold )
53- integrals_dense = torch .randn (n , n ) * Q * 0.01
54- integrals_dense [~ mask ] = 0.0
55- integrals_sparse = trnsparse .from_dense (integrals_dense )
56- print (f" Integral matrix nnz: { integrals_sparse .nnz } / { n * n } " )
126+ # MO coefficients (for the trnblas transform in path 3) — orthonormal.
127+ U , _ = torch .linalg .qr (torch .randn (n , n ))
128+ C = U
57129
58- # Density matrix (random SPD for demo)
59- P = torch .randn (n , n ) * 0.1
60- P = P @ P .T
130+ # --- Path 1: unfused ---
131+ J_unfused , t_unfused = _unfused_path (integrals_dense , diag_integrals , P , args .threshold )
61132
62- # Sparse Fock build: J_μν = Σ_λσ P_λσ * (μν|λσ)
63- # Approximated here as SpMM: J ≈ sparse_integrals @ P
64- t0 = time .perf_counter ()
65- J_sparse = trnsparse .spmm (integrals_sparse , P )
66- t_sparse = time .perf_counter () - t0
133+ # --- Path 2: fused ---
134+ J_fused , t_fused = _fused_path (integrals_dense , diag_integrals , P , args .threshold )
67135
68- # Dense reference
69- t0 = time .perf_counter ()
70- J_dense = integrals_dense @ P
71- t_dense = time .perf_counter () - t0
136+ # --- Path 3: trnblas MO transform ---
137+ F_MO , t_transform , backend = _full_fock_build (J_fused , C )
138+
139+ # --- Sparsity stats for context ---
140+ # Build the pair-bound matrix for reporting stats at the matmul scale.
141+ pair_bound = Q .unsqueeze (- 1 ) * Q .unsqueeze (0 )
142+ stats = trnsparse .sparsity_stats (pair_bound , args .threshold ** 0.5 )
72143
73- error = torch .linalg .norm (J_sparse - J_dense ).item ()
74- print (f"\n Sparse SpMM: { t_sparse :.4f} s" )
75- print (f" Dense matmul: { t_dense :.4f} s" )
76- print (f" Error: { error :.2e} " )
144+ print ()
145+ print (" Sparsity statistics:" )
146+ print (f" Total shell pairs: { stats ['total_pairs' ]} " )
147+ print (f" Significant pairs: { stats ['significant_pairs' ]} " )
148+ print (f" Pair sparsity: { stats ['pair_sparsity' ]:.1%} " )
149+ print ()
150+ print (" Coulomb build timings:" )
151+ print (f" Path 1 (unfused, 4-step): { t_unfused * 1e3 :8.3f} ms" )
152+ print (
153+ f" Path 2 (fused screened_spmm): { t_fused * 1e3 :8.3f} ms ({ t_unfused / t_fused :.2f} x vs unfused)"
154+ )
155+ print ()
156+ print (" Full Fock build (trnsparse → trnblas):" )
157+ print (f" MO transform (C.T @ J @ C): { t_transform * 1e3 :8.3f} ms via { backend } " )
158+ print ()
159+ print (f" Unfused/fused J agreement: max |ΔJ| = { (J_unfused - J_fused ).abs ().max ().item ():.2e} " )
160+ print (f" F_MO shape: { tuple (F_MO .shape )} , mean |F_MO| = { F_MO .abs ().mean ().item ():.3e} " )
77161
78162
79163if __name__ == "__main__" :
0 commit comments