Skip to content

Commit 449657b

Browse files
committed
v0.8.0: SSOR preconditioner for SPD systems (#28)
Add ssor_preconditioner(A, omega=1.0) to trnsolver.iterative. Applies M^{-1} r via forward triangular solve (D + ωL) t = r, diagonal scaling v = ω(2-ω) diag(A) ⊙ t, and backward solve (D + ωL^T) z = v. ω=1 is symmetric Gauss-Seidel; converges faster than Jacobi on coupled matrices (1D Laplacian, FEM stiffness). BF16/FP16 promoted to FP32 in factory and closure. SSOR benchmark added to bench_solver.py. Closes SSOR item in #28.
1 parent 07b2ba5 commit 449657b

8 files changed

Lines changed: 167 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.0] — 2026-04-17
11+
12+
### Added
13+
14+
- **`ssor_preconditioner(A, omega=1.0)`** in `trnsolver.iterative`. SSOR
15+
(Symmetric Successive Over-Relaxation) preconditioner for symmetric positive
16+
definite A. Applies M^{-1} r via three steps: (1) forward triangular solve
17+
(D + ω L) t = r, (2) diagonal scaling v = ω(2-ω) diag(A) ⊙ t, (3) backward
18+
triangular solve (D + ω L^T) z = v. At ω = 1 this reduces to the symmetric
19+
Gauss-Seidel step. M_SSOR is SPD when A is SPD and ω ∈ (0, 2), so it is safe
20+
as a CG preconditioner. SSOR outperforms scalar Jacobi on matrices with
21+
off-diagonal coupling (1D Laplacian, FEM stiffness, density-fitting metrics).
22+
BF16/FP16 inputs are promoted to FP32 in both the factory and the returned
23+
closure (consistent with the dtype-promotion pattern in #19). Adds the final
24+
preconditioner item from the Phase 3 tracker (#28); SSOR benchmark added to
25+
`benchmarks/bench_solver.py`.
26+
1027
## [0.7.0] — 2026-04-17
1128

1229
### Added

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ Ap = trnsolver.pinv(A)
5757
# Iterative solvers with preconditioners
5858
precond = trnsolver.jacobi_preconditioner(A)
5959
blk_precond = trnsolver.block_jacobi_preconditioner(A, block_size=16)
60-
x, iters, res = trnsolver.cg(A, b, M=blk_precond, tol=1e-8)
60+
ssor_precond = trnsolver.ssor_preconditioner(A, omega=1.0) # symmetric Gauss-Seidel
61+
x, iters, res = trnsolver.cg(A, b, M=ssor_precond, tol=1e-8)
6162
x, iters, res = trnsolver.gmres(A, b, tol=1e-6)
6263
```
6364

@@ -76,6 +77,8 @@ Demonstrates the self-consistent-field iteration: build Fock matrix → solve ge
7677

7778
## Status
7879

80+
**v0.8.0**`ssor_preconditioner(A, omega=1.0)` (#28). SSOR preconditioner for SPD systems: two triangular solves + diagonal scaling per application. Outperforms scalar Jacobi on coupled matrices (1D Laplacian, FEM stiffness). ω ∈ (0, 2); ω=1 is symmetric Gauss-Seidel.
81+
7982
**v0.7.0** — BF16/FP16 dtype support across the full public API (#19). All entry points (`cholesky`, `lu`, `qr`, `solve`, `solve_spd`, `inv_spd`, `pinv`, `inv_sqrt_spd`, `inv_sqrt_spd_ns`, `eigh`, `eigh_generalized`, `cg`, `gmres`, `block_jacobi_preconditioner`) accept BF16/FP16 inputs, upcast to FP32 internally, and restore the original dtype on output.
8083

8184
**v0.6.0**`eigh` subspace rotation refinement: one Rayleigh-Ritz step (V^T A V re-diagonalization) after Householder-QR reduces eigenvector residuals by 1–2 orders of magnitude for n ≥ 64 (#31). `solve_spd` gains `iterative_refinement=True`: mixed-precision FP64 residual + second Cholesky solve for SPD systems with cond up to ~1e7 (#32).
@@ -87,16 +90,17 @@ Demonstrates the self-consistent-field iteration: build Fock matrix → solve ge
8790
| Eigensolvers | `eigh`, `eigh_generalized` | `svd` (Jacobi-SVD, Phase 3) |
8891
| Factorizations | `cholesky`, `lu`, `qr`, `pinv` | `schur` (implicit-shift QR, Phase 3) |
8992
| Direct solvers | `solve`, `solve_spd`, `inv_spd`, `inv_sqrt_spd`, `inv_sqrt_spd_ns` ||
90-
| Iterative | `cg` (w/ preconditioner), `gmres` | SSOR (#16, v0.6.0) |
91-
| Preconditioners | `jacobi_preconditioner`, `block_jacobi_preconditioner` | SSOR (v0.6.0) |
93+
| Iterative | `cg` (w/ preconditioner), `gmres` | |
94+
| Preconditioners | `jacobi_preconditioner`, `block_jacobi_preconditioner`, `ssor_preconditioner` | |
9295

9396
**Roadmap:**
9497
- **v0.4.0** — NKI Householder-QR `eigh` validated on trn1.2xlarge (#9, #12, #38)
9598
- **v0.4.1**`eigh_generalized` NKI triangular-solve path via `trnblas.trsm` (#11)
9699
- **v0.5.0** — Newton-Schulz trnblas.gemm (#14), FP64 CG/GMRES dots + Rayleigh refinement (#27), block-Jacobi (#16), `pinv` (#22)
97100
- **v0.6.0** — eigh subspace rotation refinement (#31), solve_spd iterative refinement (#32)
98101
- **v0.7.0** — BF16/FP16 across the full API (#19) ✓
99-
- **v0.8.0+** — SSOR preconditioner, multi-NeuronCore parallel Jacobi (#20)
102+
- **v0.8.0** — SSOR preconditioner (#28) ✓
103+
- **v0.9.0+** — multi-NeuronCore parallel Jacobi (#20)
100104

101105
## Operations
102106

@@ -117,6 +121,7 @@ Demonstrates the self-consistent-field iteration: build Fock matrix → solve ge
117121
| Iterative | `gmres` | GMRES (general systems) |
118122
| Iterative | `jacobi_preconditioner` | Diagonal preconditioner for CG |
119123
| Iterative | `block_jacobi_preconditioner` | Block-diagonal Cholesky preconditioner for CG |
124+
| Iterative | `ssor_preconditioner` | SSOR / symmetric Gauss-Seidel preconditioner for CG |
120125

121126
## Benchmarks
122127

@@ -132,7 +137,7 @@ All six siblings are on PyPI, along with the umbrella meta-package:
132137
| [trnfft](https://github.com/trnsci/trnfft) | FFT and complex-valued tensors | v0.8.0 |
133138
| [trnblas](https://github.com/trnsci/trnblas) | BLAS Level 1–3 | v0.4.0 |
134139
| [trnrand](https://github.com/trnsci/trnrand) | Philox / Sobol / Halton RNG | v0.1.0 |
135-
| trnsolver | Linear solvers and eigendecomposition | **v0.7.0** |
140+
| trnsolver | Linear solvers and eigendecomposition | **v0.8.0** |
136141
| [trnsparse](https://github.com/trnsci/trnsparse) | Sparse matrix operations | v0.1.1 |
137142
| [trntensor](https://github.com/trnsci/trntensor) | Tensor contractions (einsum, TT/Tucker) | v0.1.1 |
138143

benchmarks/bench_solver.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ def test_cg_jacobi_precond(self, benchmark, spd_matrix, random_vector):
135135
M = trnsolver.jacobi_preconditioner(spd_matrix)
136136
benchmark(trnsolver.cg, spd_matrix, random_vector, M=M)
137137

138+
def test_cg_ssor_precond(self, benchmark, spd_matrix, random_vector):
139+
M = trnsolver.ssor_preconditioner(spd_matrix)
140+
benchmark(trnsolver.cg, spd_matrix, random_vector, M=M)
141+
138142

139143
class TestGMRES:
140144
def test_gmres(self, benchmark, random_matrix, random_vector):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "trnsolver"
7-
version = "0.7.0"
7+
version = "0.8.0"
88
description = "Linear solvers and eigendecomposition for AWS Trainium via NKI"
99
readme = "README.md"
1010
license = "Apache-2.0"

tests/test_dtype.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,13 @@ def test_gmres(self, dtype):
162162
assert x.dtype == dtype
163163
x_ref, _, _ = trnsolver.gmres(A_fp32, b_fp32, tol=1e-4)
164164
assert torch.allclose(x.float(), x_ref, atol=2e-1, rtol=1e-1)
165+
166+
def test_ssor(self, dtype):
167+
A_fp32 = _make_spd(16)
168+
b_fp32 = torch.randn(16)
169+
A = A_fp32.to(dtype)
170+
b = b_fp32.to(dtype)
171+
M = trnsolver.ssor_preconditioner(A)
172+
x, iters, res = trnsolver.cg(A, b, M=M, tol=1e-4)
173+
assert x.dtype == dtype
174+
assert iters > 0

tests/test_iterative.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,72 @@ def test_block_size_1_matches_jacobi(self, spd_matrix):
115115
np.testing.assert_allclose(M_block1(r).numpy(), M_scalar(r).numpy(), atol=1e-6)
116116

117117

118+
class TestSSORPreconditioner:
119+
def test_reduces_iterations_vs_jacobi(self):
120+
# 1D Laplacian — classical test case where symmetric Gauss-Seidel (omega=1)
121+
# beats scalar Jacobi in CG iteration count.
122+
n = 64
123+
ones = torch.ones(n - 1)
124+
A = 2 * torch.eye(n) - torch.diag(ones, 1) - torch.diag(ones, -1)
125+
b = torch.randn(n)
126+
127+
M_jac = trnsolver.jacobi_preconditioner(A)
128+
M_ssor = trnsolver.ssor_preconditioner(A, omega=1.0)
129+
130+
_, iters_jac, _ = trnsolver.cg(A, b, tol=1e-8, maxiter=500, M=M_jac)
131+
_, iters_ssor, _ = trnsolver.cg(A, b, tol=1e-8, maxiter=500, M=M_ssor)
132+
133+
assert iters_ssor < iters_jac
134+
135+
def test_omega_1_converges(self, spd_matrix):
136+
n = 16
137+
A = spd_matrix(n)
138+
b = torch.randn(n)
139+
M = trnsolver.ssor_preconditioner(A, omega=1.0)
140+
x, iters, res = trnsolver.cg(A, b, tol=1e-8, maxiter=200, M=M)
141+
assert res < 1e-7
142+
assert iters > 0
143+
144+
def test_omega_range(self, spd_matrix):
145+
n = 16
146+
A = spd_matrix(n)
147+
b = torch.randn(n)
148+
for omega in (0.5, 1.5):
149+
M = trnsolver.ssor_preconditioner(A, omega=omega)
150+
_, iters, res = trnsolver.cg(A, b, tol=1e-6, maxiter=200, M=M)
151+
assert res < 1e-5, f"omega={omega} did not converge: res={res}"
152+
153+
def test_invalid_omega(self, spd_matrix):
154+
A = spd_matrix(8)
155+
with pytest.raises(ValueError, match="omega"):
156+
trnsolver.ssor_preconditioner(A, omega=0.0)
157+
with pytest.raises(ValueError, match="omega"):
158+
trnsolver.ssor_preconditioner(A, omega=2.0)
159+
160+
def test_apply_matches_manual(self):
161+
# Small n=4 case: manually compute forward/backward solve and compare.
162+
torch.manual_seed(5)
163+
n = 4
164+
Q, _ = torch.linalg.qr(torch.randn(n, n))
165+
A = Q @ torch.diag(torch.tensor([1.0, 2.0, 3.0, 4.0])) @ Q.T
166+
r = torch.randn(n)
167+
omega = 1.0
168+
169+
d = torch.diagonal(A)
170+
L_factor = omega * torch.tril(A, diagonal=-1) + torch.diag(d)
171+
U_factor = L_factor.T
172+
dscale = omega * (2.0 - omega) * d
173+
174+
t = torch.linalg.solve_triangular(L_factor, r.unsqueeze(-1), upper=False).squeeze(-1)
175+
v = dscale * t
176+
z_ref = torch.linalg.solve_triangular(U_factor, v.unsqueeze(-1), upper=True).squeeze(-1)
177+
178+
M = trnsolver.ssor_preconditioner(A, omega=omega)
179+
z = M(r)
180+
181+
np.testing.assert_allclose(z.numpy(), z_ref.numpy(), atol=1e-6)
182+
183+
118184
class TestGMRES:
119185
def test_identity(self):
120186
A = torch.eye(4)

trnsolver/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Part of the trnsci scientific computing suite.
77
"""
88

9-
__version__ = "0.7.0"
9+
__version__ = "0.8.0"
1010

1111
# Eigenvalue decomposition
1212
from .eigen import eigh, eigh_generalized
@@ -15,7 +15,13 @@
1515
from .factor import cholesky, inv_spd, inv_sqrt_spd, inv_sqrt_spd_ns, lu, pinv, qr, solve, solve_spd
1616

1717
# Iterative solvers
18-
from .iterative import block_jacobi_preconditioner, cg, gmres, jacobi_preconditioner
18+
from .iterative import (
19+
block_jacobi_preconditioner,
20+
cg,
21+
gmres,
22+
jacobi_preconditioner,
23+
ssor_preconditioner,
24+
)
1925

2026
# Backend control
2127
from .nki import HAS_NKI, get_backend, set_backend
@@ -39,6 +45,7 @@
3945
"gmres",
4046
"jacobi_preconditioner",
4147
"block_jacobi_preconditioner",
48+
"ssor_preconditioner",
4249
# Backend
4350
"HAS_NKI",
4451
"set_backend",

trnsolver/iterative.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,56 @@ def apply(r: torch.Tensor) -> torch.Tensor:
8282
return apply
8383

8484

85+
def ssor_preconditioner(
86+
A: torch.Tensor,
87+
omega: float = 1.0,
88+
) -> Callable[[torch.Tensor], torch.Tensor]:
89+
"""Build an SSOR preconditioner for symmetric positive definite A.
90+
91+
For symmetric A = D + L + L^T (D diagonal, L strictly lower triangular),
92+
the SSOR preconditioner with relaxation ω ∈ (0, 2) applies M^{-1} r via:
93+
1. Forward solve: (D + ω L) t = r
94+
2. Scale: v = ω(2-ω) * diag(A) ⊙ t
95+
3. Backward solve: (D + ω L^T) z = v
96+
97+
At ω = 1 this reduces to the symmetric Gauss-Seidel step. M_SSOR is SPD
98+
when A is SPD and ω ∈ (0, 2), so it is safe as a CG preconditioner.
99+
100+
SSOR outperforms scalar Jacobi on matrices with off-diagonal coupling —
101+
FEM stiffness matrices, Poisson-like systems, and density-fitting metrics
102+
with neighbour-basis coupling.
103+
104+
Args:
105+
A: Symmetric positive definite matrix (n, n).
106+
omega: Relaxation parameter in (0, 2). Default 1.0 (symmetric Gauss-Seidel).
107+
108+
Returns:
109+
Callable M(r) → approx A^{-1} @ r for use as M= argument to `cg`.
110+
111+
Raises:
112+
ValueError: If omega is not in (0, 2) or the diagonal has near-zero entries.
113+
"""
114+
if not (0.0 < omega < 2.0):
115+
raise ValueError(f"ssor_preconditioner: omega must be in (0, 2), got {omega}")
116+
A, _ = _to_fp32(A)
117+
d = torch.diagonal(A).clone()
118+
if torch.any(d.abs() < 1e-15):
119+
raise ValueError("ssor_preconditioner: diagonal has near-zero entries")
120+
# Lower triangular factor: D + ω * L_strictly_lower
121+
L_factor = omega * torch.tril(A, diagonal=-1) + torch.diag(d)
122+
U_factor = L_factor.T # valid for symmetric A
123+
dscale = omega * (2.0 - omega) * d
124+
125+
def apply(r: torch.Tensor) -> torch.Tensor:
126+
r_fp32, r_orig = _to_fp32(r)
127+
t = torch.linalg.solve_triangular(L_factor, r_fp32.unsqueeze(-1), upper=False).squeeze(-1)
128+
v = dscale * t
129+
z = torch.linalg.solve_triangular(U_factor, v.unsqueeze(-1), upper=True).squeeze(-1)
130+
return _restore(z, r_orig)
131+
132+
return apply
133+
134+
85135
def cg(
86136
A: torch.Tensor | Callable,
87137
b: torch.Tensor,

0 commit comments

Comments
 (0)