trnsparse 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trnsparse-0.3.1 → trnsparse-0.3.2}/CHANGELOG.md +29 -0
- {trnsparse-0.3.1/trnsparse.egg-info → trnsparse-0.3.2}/PKG-INFO +1 -1
- trnsparse-0.3.2/benchmarks/bench_iterative.py +50 -0
- trnsparse-0.3.2/docs/iterative_solvers.md +115 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/pyproject.toml +1 -1
- trnsparse-0.3.2/tests/test_iterative.py +135 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/__init__.py +6 -1
- trnsparse-0.3.2/trnsparse/iterative.py +190 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2/trnsparse.egg-info}/PKG-INFO +1 -1
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse.egg-info/SOURCES.txt +4 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/.github/workflows/ci.yml +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/.github/workflows/notify-umbrella.yml +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/.github/workflows/publish.yml +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/.gitignore +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/.pre-commit-config.yaml +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/CLAUDE.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/CONTRIBUTING.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/LICENSE +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/README.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/benchmarks/bench_bsr_spmm.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/benchmarks/bench_screening.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/benchmarks/bench_spmm.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/benchmarks/bench_spmv.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/benchmarks/conftest.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/api.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/architecture.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/aws_setup.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/benchmarks.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/index.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/installation.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/migration_scipy.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/docs/quickstart.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/examples/sparse_fock.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/infra/terraform/.terraform.lock.hcl +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/infra/terraform/README.md +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/infra/terraform/main.tf +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/mkdocs.yml +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/scripts/bench_to_md.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/scripts/run_benchmarks.sh +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/scripts/run_neuron_tests.sh +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/scripts/run_simulator_tests.sh +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/setup.cfg +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/conftest.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_bsr.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_formats.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_nki_bsr.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_nki_sim.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_nki_spmm.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_ops.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/tests/test_screening.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/formats.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/nki/__init__.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/nki/dispatch.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/nki/kernels.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/ops.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse/screening.py +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse.egg-info/dependency_links.txt +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse.egg-info/requires.txt +0 -0
- {trnsparse-0.3.1 → trnsparse-0.3.2}/trnsparse.egg-info/top_level.txt +0 -0
|
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.2] — 2026-04-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`cg_bsr`** and **`power_iteration_bsr`** — Conjugate Gradient and
|
|
13
|
+
power iteration on block-sparse row matrices. Plumbing on top of
|
|
14
|
+
`bsr_spmm` (one kernel dispatch per iteration). Closes Phase 1 of
|
|
15
|
+
#22 on-chip iterative solvers.
|
|
16
|
+
- **`jacobi_preconditioner_bsr(A)`** — builds a diagonal preconditioner
|
|
17
|
+
for `cg_bsr`'s `M=` argument.
|
|
18
|
+
- **`bsr_diagonal(A)`** — extracts the main diagonal from a BSR matrix.
|
|
19
|
+
- **`docs/iterative_solvers.md`** — design note covering the v0.3.2
|
|
20
|
+
plumbing and the v0.4.0 fused-kernel goal (#24). Explains the
|
|
21
|
+
architectural win Trainium offers (A SBUF-resident across iterations)
|
|
22
|
+
vs the current per-iteration HBM round-trip.
|
|
23
|
+
- **`tests/test_iterative.py`** — 8 CPU tests including scipy parity at
|
|
24
|
+
`atol=1e-4` on a 128×128 SPD system.
|
|
25
|
+
- **`benchmarks/bench_iterative.py`** — cg_bsr vs scipy.sparse.linalg.cg.
|
|
26
|
+
At 128×128 SPD: scipy 310 μs, trnsparse 369 μs (1.19×).
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
|
|
30
|
+
- Algorithm body for CG is a local copy of `trnsolver.iterative.cg`;
|
|
31
|
+
kept local to avoid a cross-repo runtime dependency for one function.
|
|
32
|
+
- v0.4.0 will layer the fused CG/power-iteration NKI kernel on top —
|
|
33
|
+
tracked in #24. The API stays stable across the transition; users
|
|
34
|
+
upgrading from v0.3.2 get the fused-kernel speedup automatically
|
|
35
|
+
when the fused path is available.
|
|
36
|
+
|
|
8
37
|
## [0.3.1] — 2026-04-14
|
|
9
38
|
|
|
10
39
|
### Changed
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Iterative-solver benchmarks: trnsparse.cg_bsr vs scipy baseline.
|
|
2
|
+
|
|
3
|
+
The v0.3.2 plumbing dispatches one `bsr_spmm` call per CG iteration.
|
|
4
|
+
On NKI that means one kernel launch + HBM round-trip per iteration.
|
|
5
|
+
Expect the current path to be dominated by dispatch overhead compared
|
|
6
|
+
to scipy's compiled C loop, which motivates the v0.4.0 fused-kernel
|
|
7
|
+
follow-up.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
import trnsparse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(params=[128, 256])
|
|
19
|
+
def iter_size(request):
|
|
20
|
+
return request.param
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def spd_bsr_and_dense(iter_size):
|
|
25
|
+
torch.manual_seed(0)
|
|
26
|
+
n = iter_size
|
|
27
|
+
M = torch.randn(n, n)
|
|
28
|
+
A_dense = M @ M.T + n * torch.eye(n)
|
|
29
|
+
A_bsr = trnsparse.BSRMatrix.from_dense(A_dense, block_size=128)
|
|
30
|
+
b = torch.randn(n)
|
|
31
|
+
return A_dense, A_bsr, b
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_cg_bsr_trnsparse(benchmark, spd_bsr_and_dense):
|
|
35
|
+
_, A_bsr, b = spd_bsr_and_dense
|
|
36
|
+
benchmark(lambda: trnsparse.cg_bsr(A_bsr, b, tol=1e-8, max_iter=2 * b.shape[0]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cg_scipy(benchmark, spd_bsr_and_dense):
|
|
40
|
+
sp = pytest.importorskip("scipy.sparse")
|
|
41
|
+
spla = pytest.importorskip("scipy.sparse.linalg")
|
|
42
|
+
A_dense, _, b = spd_bsr_and_dense
|
|
43
|
+
A_scipy = sp.csr_matrix(A_dense.numpy())
|
|
44
|
+
b_np = b.numpy()
|
|
45
|
+
benchmark(lambda: spla.cg(A_scipy, b_np, rtol=1e-8, maxiter=2 * len(b_np)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_power_iteration_trnsparse(benchmark, spd_bsr_and_dense):
|
|
49
|
+
_, A_bsr, _ = spd_bsr_and_dense
|
|
50
|
+
benchmark(lambda: trnsparse.power_iteration_bsr(A_bsr, max_iter=500, tol=1e-9))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Iterative solvers over BSR
|
|
2
|
+
|
|
3
|
+
trnsparse v0.3.2 adds `cg_bsr` and `power_iteration_bsr` — Conjugate
|
|
4
|
+
Gradient and power iteration on block-sparse row matrices. The API is
|
|
5
|
+
stable; the architectural story below explains why there's a v0.4.0
|
|
6
|
+
follow-up.
|
|
7
|
+
|
|
8
|
+
## Why this matters
|
|
9
|
+
|
|
10
|
+
Large SPD linear systems and dominant-eigenpair problems show up
|
|
11
|
+
across scientific computing:
|
|
12
|
+
|
|
13
|
+
- **Quantum chemistry**: Hamiltonian eigenvalue problems (HF, DFT,
|
|
14
|
+
CI), response equations (CPSCF).
|
|
15
|
+
- **PDE discretizations**: stiffness-matrix solves for finite element
|
|
16
|
+
methods, graph Laplacian systems.
|
|
17
|
+
- **Graph learning**: spectral embeddings, PageRank-like iterations.
|
|
18
|
+
|
|
19
|
+
The matrix `A` in each case is typically block-sparse (Fock matrices
|
|
20
|
+
after Schwarz screening; FEM stiffness tied to mesh connectivity;
|
|
21
|
+
graph adjacency). BSR is the Trainium-native representation for those
|
|
22
|
+
matrices (see `architecture.md`).
|
|
23
|
+
|
|
24
|
+
## v0.3.2 — plumbing
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import trnsparse
|
|
28
|
+
|
|
29
|
+
A = trnsparse.BSRMatrix.from_dense(fock_matrix, block_size=128)
|
|
30
|
+
b = compute_rhs()
|
|
31
|
+
|
|
32
|
+
x, iters, rel = trnsparse.cg_bsr(A, b, tol=1e-6, max_iter=1000)
|
|
33
|
+
# Jacobi-preconditioned variant:
|
|
34
|
+
M = trnsparse.jacobi_preconditioner_bsr(A)
|
|
35
|
+
x, iters, rel = trnsparse.cg_bsr(A, b, tol=1e-6, M=M)
|
|
36
|
+
|
|
37
|
+
lam, v, iters = trnsparse.power_iteration_bsr(A, max_iter=500)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Under the hood, each CG iteration calls `bsr_spmm(A, x.unsqueeze(1))`
|
|
41
|
+
once. On the NKI backend that's one kernel dispatch + one HBM
|
|
42
|
+
round-trip per iteration. On CPU it's `torch.sparse`-backed and
|
|
43
|
+
roughly on par with `scipy.sparse.linalg.cg` (benchmarked: 369 μs vs
|
|
44
|
+
310 μs at 128×128 SPD, 1.19× slower).
|
|
45
|
+
|
|
46
|
+
## v0.4.0 — fused kernel with SBUF-resident A
|
|
47
|
+
|
|
48
|
+
The architectural claim from
|
|
49
|
+
[#22](https://github.com/trnsci/trnsparse/issues/22): Trainium's 32 GB
|
|
50
|
+
SBUF per NeuronCore fits a 5000×5000 BSR Hamiltonian on-chip. CG
|
|
51
|
+
doesn't need to round-trip `A` to HBM at all — only `x`, `r`, and `p`.
|
|
52
|
+
|
|
53
|
+
The shape of the v0.4.0 kernel:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
@nki.jit
|
|
57
|
+
def _cg_spd_kernel(A_blocks, A_cols, A_row_ptrs, b, max_iter):
|
|
58
|
+
# Load A blocks once into SBUF at the top.
|
|
59
|
+
A_sbuf = nl.load(A_blocks)
|
|
60
|
+
|
|
61
|
+
# State: x, r, p in SBUF registers.
|
|
62
|
+
x = nl.zeros(...)
|
|
63
|
+
r = nl.copy(b)
|
|
64
|
+
p = nl.copy(r)
|
|
65
|
+
rr = nl.reduce(r * r)
|
|
66
|
+
|
|
67
|
+
for k in nl.affine_range(max_iter):
|
|
68
|
+
Ap = _bsr_matvec_sbuf(A_sbuf, A_cols, A_row_ptrs, p) # all SBUF
|
|
69
|
+
pAp = nl.reduce(p * Ap)
|
|
70
|
+
alpha = rr / pAp
|
|
71
|
+
x = x + alpha * p
|
|
72
|
+
r = r - alpha * Ap
|
|
73
|
+
rr_new = nl.reduce(r * r)
|
|
74
|
+
beta = rr_new / rr
|
|
75
|
+
p = r + beta * p
|
|
76
|
+
rr = rr_new
|
|
77
|
+
|
|
78
|
+
return x, residual_norm_history
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Fixed `max_iter`, no early exit — returning the full residual history
|
|
82
|
+
lets the host pick the convergence point post-hoc (standard
|
|
83
|
+
NKI-inside-loop constraint: no dynamic control flow).
|
|
84
|
+
|
|
85
|
+
**Expected regime where this wins**: when
|
|
86
|
+
`max_iter × dispatch_overhead > fused_kernel_cost + hbm_load_A_once`.
|
|
87
|
+
For a 4000×4000 BSR Hamiltonian with ~100 iterations to convergence,
|
|
88
|
+
that's roughly an order of magnitude of wall-time reduction.
|
|
89
|
+
|
|
90
|
+
**What needs to land first**:
|
|
91
|
+
|
|
92
|
+
1. Simulator-iterated kernel skeleton (now tractable thanks to the
|
|
93
|
+
`nki-simulator` CI gate — see the NKI 0.3.0 migration in v0.3.1).
|
|
94
|
+
2. SBUF sizing model for `A` — some workloads overflow 32 GB; then
|
|
95
|
+
fall back to the v0.3.2 plumbing.
|
|
96
|
+
3. Dispatcher logic in `cg_bsr` that picks the fused kernel when
|
|
97
|
+
`max_iter * n` exceeds a threshold.
|
|
98
|
+
|
|
99
|
+
Tracked in a dedicated sub-issue on `trnsci/trnsparse`.
|
|
100
|
+
|
|
101
|
+
## Why CG and power iteration, not GMRES / Lanczos / Davidson
|
|
102
|
+
|
|
103
|
+
v0.3.2 covers the two most common algorithm families:
|
|
104
|
+
|
|
105
|
+
- **CG** for SPD systems — the workhorse for Hamiltonian solves,
|
|
106
|
+
stiffness systems, and many PDE discretizations.
|
|
107
|
+
- **Power iteration** for dominant eigenpairs — the starting point
|
|
108
|
+
for spectral methods, PageRank-like fixed points, and the iteration
|
|
109
|
+
core inside Lanczos / Arnoldi / Davidson.
|
|
110
|
+
|
|
111
|
+
GMRES (general non-symmetric systems), Lanczos / Arnoldi (full
|
|
112
|
+
spectrum), and Davidson (interior eigenvalues) are follow-ups when
|
|
113
|
+
users ask for them. The v0.4.0 fused kernel's structure (load A once,
|
|
114
|
+
iterate on-chip) generalizes trivially to those variants — it's the
|
|
115
|
+
same architectural pattern.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Iterative solver tests on BSR matrices (CPU plumbing — #22 Phase 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
import trnsparse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _random_spd_bsr(
|
|
13
|
+
n: int, block_size: int = 128, seed: int = 0
|
|
14
|
+
) -> tuple[torch.Tensor, trnsparse.BSRMatrix]:
|
|
15
|
+
"""Build a dense SPD matrix of size (n, n), return (dense, BSR view).
|
|
16
|
+
|
|
17
|
+
`A = M @ M.T + n*I` guarantees SPD. Block-storing a dense SPD matrix
|
|
18
|
+
is valid — BSR just chooses which blocks to keep; SPD-ness is a
|
|
19
|
+
per-element property that survives the block partition.
|
|
20
|
+
"""
|
|
21
|
+
torch.manual_seed(seed)
|
|
22
|
+
M = torch.randn(n, n)
|
|
23
|
+
A_dense = M @ M.T + n * torch.eye(n)
|
|
24
|
+
return A_dense, trnsparse.BSRMatrix.from_dense(A_dense, block_size=block_size)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestCgBsr:
|
|
28
|
+
def test_identity_converges_immediately(self):
|
|
29
|
+
"""A = I → x = b in one iteration (or zero for zero RHS)."""
|
|
30
|
+
n = 128
|
|
31
|
+
I_dense = torch.eye(n)
|
|
32
|
+
I_bsr = trnsparse.BSRMatrix.from_dense(I_dense, block_size=128)
|
|
33
|
+
b = torch.randn(n)
|
|
34
|
+
|
|
35
|
+
x, iters, rel = trnsparse.cg_bsr(I_bsr, b, tol=1e-8, max_iter=50)
|
|
36
|
+
torch.testing.assert_close(x, b, atol=1e-5, rtol=1e-5)
|
|
37
|
+
assert iters <= 2, f"identity should converge in 1 iter, took {iters}"
|
|
38
|
+
assert rel < 1e-8
|
|
39
|
+
|
|
40
|
+
def test_parity_vs_scipy(self):
|
|
41
|
+
"""Solution from cg_bsr matches scipy.sparse.linalg.cg within tol."""
|
|
42
|
+
sp = pytest.importorskip("scipy.sparse")
|
|
43
|
+
spla = pytest.importorskip("scipy.sparse.linalg")
|
|
44
|
+
|
|
45
|
+
n = 128
|
|
46
|
+
A_dense, A_bsr = _random_spd_bsr(n, block_size=128, seed=1)
|
|
47
|
+
b = torch.randn(n)
|
|
48
|
+
|
|
49
|
+
x_ours, iters_ours, rel_ours = trnsparse.cg_bsr(A_bsr, b, tol=1e-8, max_iter=2 * n)
|
|
50
|
+
# scipy's cg takes rtol (relative to ||b||) by default in modern scipy
|
|
51
|
+
A_scipy = sp.csr_matrix(A_dense.numpy())
|
|
52
|
+
x_scipy, info = spla.cg(A_scipy, b.numpy(), rtol=1e-8, maxiter=2 * n)
|
|
53
|
+
|
|
54
|
+
assert info == 0, "scipy CG should converge on this well-conditioned SPD system"
|
|
55
|
+
np.testing.assert_allclose(x_ours.numpy(), x_scipy, atol=1e-4, rtol=1e-4)
|
|
56
|
+
assert rel_ours < 1e-7
|
|
57
|
+
|
|
58
|
+
def test_jacobi_preconditioner_fewer_iters(self):
|
|
59
|
+
"""A strongly diagonally-dominant system should converge faster
|
|
60
|
+
with Jacobi preconditioning than without.
|
|
61
|
+
"""
|
|
62
|
+
torch.manual_seed(2)
|
|
63
|
+
n = 256
|
|
64
|
+
# Diagonally dominant: large diagonal, small off-diag.
|
|
65
|
+
A_dense = 0.01 * torch.randn(n, n)
|
|
66
|
+
A_dense = 0.5 * (A_dense + A_dense.T) # symmetric
|
|
67
|
+
A_dense = A_dense + torch.diag(1.0 + 10.0 * torch.rand(n)) # SPD
|
|
68
|
+
A_bsr = trnsparse.BSRMatrix.from_dense(A_dense, block_size=128)
|
|
69
|
+
b = torch.randn(n)
|
|
70
|
+
|
|
71
|
+
_, iters_plain, _ = trnsparse.cg_bsr(A_bsr, b, tol=1e-8, max_iter=500)
|
|
72
|
+
M = trnsparse.jacobi_preconditioner_bsr(A_bsr)
|
|
73
|
+
_, iters_precond, _ = trnsparse.cg_bsr(A_bsr, b, tol=1e-8, max_iter=500, M=M)
|
|
74
|
+
|
|
75
|
+
assert iters_precond <= iters_plain, (
|
|
76
|
+
f"Jacobi preconditioner should not make it worse "
|
|
77
|
+
f"(plain={iters_plain}, precond={iters_precond})"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def test_zero_rhs_returns_zero(self):
|
|
81
|
+
"""b = 0 → x = 0 in 0 iterations."""
|
|
82
|
+
_, A_bsr = _random_spd_bsr(128, block_size=128, seed=3)
|
|
83
|
+
b = torch.zeros(128)
|
|
84
|
+
x, iters, rel = trnsparse.cg_bsr(A_bsr, b)
|
|
85
|
+
torch.testing.assert_close(x, b)
|
|
86
|
+
assert iters == 0
|
|
87
|
+
assert rel == 0.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestPowerIterationBsr:
|
|
91
|
+
def test_dominant_eigenvalue_diagonal(self):
|
|
92
|
+
"""Diagonal matrix: dominant eigenvalue is max(diag); eigenvector
|
|
93
|
+
is the corresponding basis vector.
|
|
94
|
+
"""
|
|
95
|
+
n = 128
|
|
96
|
+
diag = torch.linspace(0.5, 10.0, n) # strictly increasing; max = 10
|
|
97
|
+
A_dense = torch.diag(diag)
|
|
98
|
+
A_bsr = trnsparse.BSRMatrix.from_dense(A_dense, block_size=128)
|
|
99
|
+
|
|
100
|
+
lam, v, iters = trnsparse.power_iteration_bsr(A_bsr, max_iter=500, tol=1e-10)
|
|
101
|
+
assert abs(lam - 10.0) < 1e-3, f"expected ~10.0, got {lam}"
|
|
102
|
+
# Eigenvector aligns with e_{n-1} (corresponding to max diag entry).
|
|
103
|
+
assert abs(abs(v[-1].item()) - 1.0) < 1e-3
|
|
104
|
+
|
|
105
|
+
def test_dominant_eigenvalue_vs_torch(self):
|
|
106
|
+
"""Match torch.linalg.eigvalsh's largest eigenvalue within 1e-3."""
|
|
107
|
+
torch.manual_seed(4)
|
|
108
|
+
n = 128
|
|
109
|
+
A_dense, A_bsr = _random_spd_bsr(n, block_size=128, seed=4)
|
|
110
|
+
|
|
111
|
+
lam_ours, _, _ = trnsparse.power_iteration_bsr(A_bsr, max_iter=2000, tol=1e-10)
|
|
112
|
+
lam_ref = torch.linalg.eigvalsh(A_dense).max().item()
|
|
113
|
+
|
|
114
|
+
# Power iteration on a random SPD matrix has a spectral gap;
|
|
115
|
+
# 1e-3 relative tolerance is generous.
|
|
116
|
+
assert abs(lam_ours - lam_ref) / lam_ref < 1e-3, f"ours={lam_ours}, ref={lam_ref}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestBsrDiagonal:
|
|
120
|
+
def test_diagonal_of_identity(self):
|
|
121
|
+
I_bsr = trnsparse.BSRMatrix.from_dense(torch.eye(128), block_size=128)
|
|
122
|
+
d = trnsparse.bsr_diagonal(I_bsr)
|
|
123
|
+
torch.testing.assert_close(d, torch.ones(128))
|
|
124
|
+
|
|
125
|
+
def test_diagonal_of_off_diagonal_blocks_only(self):
|
|
126
|
+
"""A matrix whose only stored blocks are off-diagonal returns
|
|
127
|
+
zeros on the diagonal.
|
|
128
|
+
"""
|
|
129
|
+
torch.manual_seed(5)
|
|
130
|
+
A_dense = torch.zeros(256, 256)
|
|
131
|
+
A_dense[:128, 128:] = torch.randn(128, 128)
|
|
132
|
+
A_dense[128:, :128] = torch.randn(128, 128)
|
|
133
|
+
A_bsr = trnsparse.BSRMatrix.from_dense(A_dense, block_size=128)
|
|
134
|
+
d = trnsparse.bsr_diagonal(A_bsr)
|
|
135
|
+
torch.testing.assert_close(d, torch.zeros(256))
|
|
@@ -5,9 +5,10 @@ CSR/COO formats, SpMV, SpMM, and integral screening for
|
|
|
5
5
|
sparse scientific computing. Part of the trnsci scientific computing suite.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "0.3.
|
|
8
|
+
__version__ = "0.3.2"
|
|
9
9
|
|
|
10
10
|
from .formats import BSRMatrix, COOMatrix, CSRMatrix, eye_sparse, from_dense, from_scipy
|
|
11
|
+
from .iterative import bsr_diagonal, cg_bsr, jacobi_preconditioner_bsr, power_iteration_bsr
|
|
11
12
|
from .nki import HAS_NKI, get_backend, set_backend
|
|
12
13
|
from .ops import (
|
|
13
14
|
bsr_spmm,
|
|
@@ -40,6 +41,10 @@ __all__ = [
|
|
|
40
41
|
"screen_quartets",
|
|
41
42
|
"density_screen",
|
|
42
43
|
"sparsity_stats",
|
|
44
|
+
"cg_bsr",
|
|
45
|
+
"power_iteration_bsr",
|
|
46
|
+
"jacobi_preconditioner_bsr",
|
|
47
|
+
"bsr_diagonal",
|
|
43
48
|
"HAS_NKI",
|
|
44
49
|
"set_backend",
|
|
45
50
|
"get_backend",
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Iterative linear solvers for BSR sparse matrices.
|
|
2
|
+
|
|
3
|
+
v0.3.2 — Phase 1 plumbing (#22). Python-level CG and power iteration
|
|
4
|
+
on top of `bsr_spmm` as the matvec. A gets reloaded from HBM on every
|
|
5
|
+
iteration; v0.4.0 (follow-up) delivers the SBUF-resident fused NKI
|
|
6
|
+
kernel that keeps A on-chip across iterations — the architectural win.
|
|
7
|
+
|
|
8
|
+
Algorithm body for CG mirrors `trnsolver.iterative.cg` at commit-time,
|
|
9
|
+
kept local to avoid a trnsolver runtime dep.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
|
|
18
|
+
from .formats import BSRMatrix
|
|
19
|
+
from .ops import bsr_spmm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bsr_diagonal(A: BSRMatrix) -> torch.Tensor:
|
|
23
|
+
"""Extract the main diagonal of a BSR matrix as a dense vector.
|
|
24
|
+
|
|
25
|
+
The diagonal lives inside the diagonal blocks (block row i at block
|
|
26
|
+
column i). Blocks that aren't diagonal contribute nothing. Returns
|
|
27
|
+
a zero vector if no diagonal block is stored (a fully zero-diagonal
|
|
28
|
+
matrix would be degenerate as a preconditioner anyway).
|
|
29
|
+
"""
|
|
30
|
+
m, _ = A.shape
|
|
31
|
+
b = A.block_size
|
|
32
|
+
M_tiles = (m + b - 1) // b
|
|
33
|
+
out = torch.zeros(M_tiles * b, dtype=A.dtype)
|
|
34
|
+
for i in range(M_tiles):
|
|
35
|
+
start = A.block_row_ptrs[i].item()
|
|
36
|
+
end = A.block_row_ptrs[i + 1].item()
|
|
37
|
+
for k in range(start, end):
|
|
38
|
+
if A.block_col_indices[k].item() == i:
|
|
39
|
+
out[i * b : (i + 1) * b] = torch.diagonal(A.blocks[k])
|
|
40
|
+
break
|
|
41
|
+
return out[:m]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _bsr_matvec(A: BSRMatrix) -> Callable[[torch.Tensor], torch.Tensor]:
|
|
45
|
+
"""Wrap `bsr_spmm` so CG / power iteration can pass it a vector."""
|
|
46
|
+
|
|
47
|
+
def matvec(x: torch.Tensor) -> torch.Tensor:
|
|
48
|
+
return bsr_spmm(A, x.unsqueeze(1)).squeeze(1)
|
|
49
|
+
|
|
50
|
+
return matvec
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cg_bsr(
|
|
54
|
+
A: BSRMatrix,
|
|
55
|
+
b: torch.Tensor,
|
|
56
|
+
x0: torch.Tensor | None = None,
|
|
57
|
+
tol: float = 1e-6,
|
|
58
|
+
max_iter: int = 1000,
|
|
59
|
+
M: Callable[[torch.Tensor], torch.Tensor] | torch.Tensor | None = None,
|
|
60
|
+
) -> tuple[torch.Tensor, int, float]:
|
|
61
|
+
"""Conjugate Gradient solver for `A @ x = b` with A SPD and BSR-stored.
|
|
62
|
+
|
|
63
|
+
Thin specialization of the standard CG loop: the matvec goes through
|
|
64
|
+
`bsr_spmm`, so on the NKI backend each iteration dispatches one
|
|
65
|
+
kernel call. That amortizes poorly across many iterations — the
|
|
66
|
+
v0.4.0 fused kernel fuses the loop and keeps A SBUF-resident.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
A: SPD BSR matrix (N, N).
|
|
70
|
+
b: Right-hand side, shape (N,).
|
|
71
|
+
x0: Initial guess (default: zeros).
|
|
72
|
+
tol: Relative-residual tolerance on `||r|| / ||b||`.
|
|
73
|
+
max_iter: Iteration cap.
|
|
74
|
+
M: Preconditioner — callable `M(r) → M^{-1} r` or a dense
|
|
75
|
+
inverse tensor. Build a Jacobi preconditioner from
|
|
76
|
+
`bsr_diagonal(A)` for the common diagonally-dominant case.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
`(x, iters, rel_residual)` — solution, iteration count, final
|
|
80
|
+
relative residual.
|
|
81
|
+
"""
|
|
82
|
+
n = b.shape[0]
|
|
83
|
+
matvec = _bsr_matvec(A)
|
|
84
|
+
|
|
85
|
+
if M is None:
|
|
86
|
+
precond: Callable[[torch.Tensor], torch.Tensor] | None = None
|
|
87
|
+
elif callable(M):
|
|
88
|
+
precond = M
|
|
89
|
+
else:
|
|
90
|
+
M_tensor = M
|
|
91
|
+
|
|
92
|
+
def precond(r: torch.Tensor) -> torch.Tensor:
|
|
93
|
+
return torch.mv(M_tensor, r)
|
|
94
|
+
|
|
95
|
+
x = x0.clone() if x0 is not None else torch.zeros(n, dtype=b.dtype, device=b.device)
|
|
96
|
+
r = b - matvec(x)
|
|
97
|
+
b_norm = torch.linalg.norm(b).item()
|
|
98
|
+
if b_norm < 1e-15:
|
|
99
|
+
return x, 0, 0.0
|
|
100
|
+
|
|
101
|
+
z = precond(r) if precond is not None else r.clone()
|
|
102
|
+
p = z.clone()
|
|
103
|
+
rz = torch.dot(r, z).item()
|
|
104
|
+
|
|
105
|
+
for k in range(max_iter):
|
|
106
|
+
Ap = matvec(p)
|
|
107
|
+
pAp = torch.dot(p, Ap).item()
|
|
108
|
+
if abs(pAp) < 1e-30:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
alpha = rz / pAp
|
|
112
|
+
x = x + alpha * p
|
|
113
|
+
r = r - alpha * Ap
|
|
114
|
+
|
|
115
|
+
r_norm = torch.linalg.norm(r).item()
|
|
116
|
+
rel = r_norm / b_norm
|
|
117
|
+
if rel < tol:
|
|
118
|
+
return x, k + 1, rel
|
|
119
|
+
|
|
120
|
+
z = precond(r) if precond is not None else r
|
|
121
|
+
rz_new = torch.dot(r, z).item()
|
|
122
|
+
beta = rz_new / rz
|
|
123
|
+
p = z + beta * p
|
|
124
|
+
rz = rz_new
|
|
125
|
+
|
|
126
|
+
return x, max_iter, r_norm / b_norm
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def jacobi_preconditioner_bsr(
|
|
130
|
+
A: BSRMatrix,
|
|
131
|
+
) -> Callable[[torch.Tensor], torch.Tensor]:
|
|
132
|
+
"""Build a Jacobi (diagonal) preconditioner for a BSR matrix.
|
|
133
|
+
|
|
134
|
+
Returns a callable `M(r) = r / diag(A)`. Cheap + effective when A
|
|
135
|
+
is diagonally dominant.
|
|
136
|
+
"""
|
|
137
|
+
d = bsr_diagonal(A)
|
|
138
|
+
if torch.any(d.abs() < 1e-15):
|
|
139
|
+
raise ValueError("Jacobi preconditioner: diagonal has near-zero entries")
|
|
140
|
+
inv_d = 1.0 / d
|
|
141
|
+
|
|
142
|
+
def precond(r: torch.Tensor) -> torch.Tensor:
|
|
143
|
+
return r * inv_d
|
|
144
|
+
|
|
145
|
+
return precond
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def power_iteration_bsr(
|
|
149
|
+
A: BSRMatrix,
|
|
150
|
+
v0: torch.Tensor | None = None,
|
|
151
|
+
max_iter: int = 100,
|
|
152
|
+
tol: float = 1e-9,
|
|
153
|
+
) -> tuple[float, torch.Tensor, int]:
|
|
154
|
+
"""Find the dominant eigenpair (λ, v) of a BSR matrix via power iteration.
|
|
155
|
+
|
|
156
|
+
Converges to the eigenvector with largest `|λ|`. Tolerance is on
|
|
157
|
+
the Rayleigh-quotient change between iterations.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
A: Square BSR matrix (N, N). Doesn't need to be SPD — power
|
|
161
|
+
iteration works for any matrix with a unique dominant
|
|
162
|
+
eigenvalue, but convergence slows as the spectral gap
|
|
163
|
+
shrinks.
|
|
164
|
+
v0: Initial vector (default: random unit vector).
|
|
165
|
+
max_iter: Iteration cap.
|
|
166
|
+
tol: Convergence on `|λ_k - λ_{k-1}|`.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
`(eigenvalue, eigenvector, iters)`.
|
|
170
|
+
"""
|
|
171
|
+
n = A.shape[0]
|
|
172
|
+
matvec = _bsr_matvec(A)
|
|
173
|
+
|
|
174
|
+
v = v0.clone() if v0 is not None else torch.randn(n, dtype=A.dtype)
|
|
175
|
+
v = v / torch.linalg.norm(v)
|
|
176
|
+
|
|
177
|
+
lam_prev = 0.0
|
|
178
|
+
for k in range(max_iter):
|
|
179
|
+
Av = matvec(v)
|
|
180
|
+
lam = torch.dot(v, Av).item()
|
|
181
|
+
norm = torch.linalg.norm(Av).item()
|
|
182
|
+
if norm < 1e-30:
|
|
183
|
+
return 0.0, v, k + 1
|
|
184
|
+
v = Av / norm
|
|
185
|
+
|
|
186
|
+
if abs(lam - lam_prev) < tol:
|
|
187
|
+
return lam, v, k + 1
|
|
188
|
+
lam_prev = lam
|
|
189
|
+
|
|
190
|
+
return lam, v, max_iter
|
|
@@ -12,6 +12,7 @@ pyproject.toml
|
|
|
12
12
|
.github/workflows/notify-umbrella.yml
|
|
13
13
|
.github/workflows/publish.yml
|
|
14
14
|
benchmarks/bench_bsr_spmm.py
|
|
15
|
+
benchmarks/bench_iterative.py
|
|
15
16
|
benchmarks/bench_screening.py
|
|
16
17
|
benchmarks/bench_spmm.py
|
|
17
18
|
benchmarks/bench_spmv.py
|
|
@@ -22,6 +23,7 @@ docs/aws_setup.md
|
|
|
22
23
|
docs/benchmarks.md
|
|
23
24
|
docs/index.md
|
|
24
25
|
docs/installation.md
|
|
26
|
+
docs/iterative_solvers.md
|
|
25
27
|
docs/migration_scipy.md
|
|
26
28
|
docs/quickstart.md
|
|
27
29
|
examples/sparse_fock.py
|
|
@@ -35,6 +37,7 @@ scripts/run_simulator_tests.sh
|
|
|
35
37
|
tests/conftest.py
|
|
36
38
|
tests/test_bsr.py
|
|
37
39
|
tests/test_formats.py
|
|
40
|
+
tests/test_iterative.py
|
|
38
41
|
tests/test_nki_bsr.py
|
|
39
42
|
tests/test_nki_sim.py
|
|
40
43
|
tests/test_nki_spmm.py
|
|
@@ -42,6 +45,7 @@ tests/test_ops.py
|
|
|
42
45
|
tests/test_screening.py
|
|
43
46
|
trnsparse/__init__.py
|
|
44
47
|
trnsparse/formats.py
|
|
48
|
+
trnsparse/iterative.py
|
|
45
49
|
trnsparse/ops.py
|
|
46
50
|
trnsparse/screening.py
|
|
47
51
|
trnsparse.egg-info/PKG-INFO
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|