Skip to content

Commit 6839ddd

Browse files
authored
Fix higher-order sparse solve gradients and optional JAX test skipping (#85)
Fix higher-order autograd support for sparse_generic_solve by routing the backward transpose solve through sparse_generic_solve rather than directly calling the raw iterative solver. This avoids differentiating through solver internals when create_graph=True, while preserving the implicit-gradient path used by the custom autograd function. Also: - preserve the solve output in the autograd context without detaching it; - add higher-order regression tests for linear_cg, minres, and BiCGSTAB; - add a non-symmetric BiCGSTAB test with an explicit transpose-solve wrapper; - fix optional JAX test skipping by moving the JAX import behind pytest.importorskip; - bump package/docs version to 0.2.3.
1 parent efa515c commit 6839ddd

5 files changed

Lines changed: 235 additions & 10 deletions

File tree

docs/source/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
project = "torchsparsegradutils"
1616
copyright = "2026, CAI4CAI research group"
1717
author = "CAI4CAI research group"
18-
release = "0.2.2"
19-
version = "0.2.2"
18+
release = "0.2.3"
19+
version = "0.2.3"
2020

2121
# -- General configuration ---------------------------------------------------
2222
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

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 = "torchsparsegradutils"
7-
version = "0.2.2"
7+
version = "0.2.3"
88
description = "A collection of utility functions to work with PyTorch sparse tensors"
99
readme = "README.md"
1010
requires-python = ">=3.10"

torchsparsegradutils/sparse_solve.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ class SparseGenericSolve(torch.autograd.Function):
438438
@staticmethod
439439
def forward(ctx, A, B, solve, transpose_solve, kwargs):
440440
grad_flag = A.requires_grad or B.requires_grad
441+
ctx.solve = solve
441442
ctx.transpose_solve = transpose_solve
442443
ctx.kwargs = kwargs # Store kwargs for backward pass
443444

@@ -449,7 +450,7 @@ def forward(ctx, A, B, solve, transpose_solve, kwargs):
449450

450451
x.requires_grad = grad_flag
451452

452-
ctx.save_for_backward(A, x.detach())
453+
ctx.save_for_backward(A, x)
453454
return x
454455

455456
@staticmethod
@@ -463,7 +464,13 @@ def backward(ctx, grad): # type: ignore[override]
463464
grad = grad.unsqueeze(-1)
464465

465466
# Backprop rule: gradB = A^{-T} grad
466-
gradB = ctx.transpose_solve(A, grad, **ctx.kwargs)
467+
gradB = sparse_generic_solve(
468+
A,
469+
grad,
470+
solve=ctx.transpose_solve,
471+
transpose_solve=ctx.solve,
472+
**ctx.kwargs,
473+
)
467474

468475
# Ensure gradient dtype matches input dtype
469476
if gradB.dtype != A.dtype:

torchsparsegradutils/tests/test_jax_bindings.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
import jax
21
import numpy as np
32
import pytest
43
import torch
54
from test_config import DEVICES
65

7-
import torchsparsegradutils as tsgu
86
import torchsparsegradutils.jax as tsgujax
97

108
# skip if JAX unavailable
11-
pytest.importorskip("jax")
9+
jax = pytest.importorskip("jax")
1210
if not tsgujax.have_jax:
1311
pytest.skip("JAX bindings unavailable, skipping jax tests", allow_module_level=True)
1412

15-
import jax.numpy as jnp
16-
1713

1814
def _id_device(d):
1915
return str(d)

torchsparsegradutils/tests/test_sparse_solve.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,132 @@ def solve_id(solve):
4242
return "default"
4343

4444

45+
def _make_differentiable_tridiag_spd(theta, layout):
46+
"""Create a small sparse SPD matrix with fixed sparsity and differentiable values."""
47+
n = theta.numel()
48+
device = theta.device
49+
50+
diag = theta.square() + 2.0
51+
off = -0.1 * torch.sigmoid(theta[:-1])
52+
53+
rows = torch.cat(
54+
[
55+
torch.arange(n, device=device),
56+
torch.arange(n - 1, device=device),
57+
torch.arange(1, n, device=device),
58+
]
59+
)
60+
cols = torch.cat(
61+
[
62+
torch.arange(n, device=device),
63+
torch.arange(1, n, device=device),
64+
torch.arange(n - 1, device=device),
65+
]
66+
)
67+
values = torch.cat([diag, off, off])
68+
69+
A = torch.sparse_coo_tensor(torch.stack([rows, cols]), values, (n, n)).coalesce()
70+
if layout == torch.sparse_csr:
71+
A = A.to_sparse_csr()
72+
return A, A.to_dense()
73+
74+
75+
def _make_differentiable_nonsymmetric_tridiag(theta, layout):
76+
"""Create a small fixed-sparsity non-symmetric diagonally dominant matrix."""
77+
n = 6
78+
device = theta.device
79+
dtype = theta.dtype
80+
81+
if theta.numel() != 3 * n - 2:
82+
raise ValueError(f"theta should have length {3 * n - 2}, got {theta.numel()}")
83+
84+
diag_theta = theta[:n]
85+
upper_theta = theta[n : n + n - 1]
86+
lower_theta = theta[n + n - 1 :]
87+
88+
diag = diag_theta.square() + 3.0
89+
upper = 0.05 * torch.tanh(upper_theta)
90+
lower = -0.08 * torch.sigmoid(lower_theta)
91+
92+
rows = torch.cat(
93+
[
94+
torch.arange(n, device=device),
95+
torch.arange(n - 1, device=device),
96+
torch.arange(1, n, device=device),
97+
]
98+
)
99+
cols = torch.cat(
100+
[
101+
torch.arange(n, device=device),
102+
torch.arange(1, n, device=device),
103+
torch.arange(n - 1, device=device),
104+
]
105+
)
106+
values = torch.cat([diag, upper, lower]).to(dtype=dtype)
107+
108+
A = torch.sparse_coo_tensor(torch.stack([rows, cols]), values, (n, n)).coalesce()
109+
if layout == torch.sparse_csr:
110+
A = A.to_sparse_csr()
111+
return A, A.to_dense()
112+
113+
114+
def _bicgstab_transpose(A, B, **kwargs):
115+
"""Solve A.T X = B using BiCGSTAB."""
116+
if A.layout == torch.sparse_csr:
117+
# A.T currently triggers aten::as_strided for sparse CSR tensors in PyTorch,
118+
# so use transpose(...).to_sparse_csr() for the CSR test case.
119+
# A.transpose(0, 1) for csr will return csc, hence the to_sparse_csr() call.
120+
return bicgstab(A.transpose(0, 1).to_sparse_csr(), B, **kwargs)
121+
return bicgstab(A.T, B, **kwargs)
122+
123+
124+
def _bicgstab_higher_order_kwargs(value_dtype):
125+
from torchsparsegradutils.utils.bicgstab import BICGSTABSettings
126+
127+
if value_dtype == torch.float32:
128+
return {"settings": BICGSTABSettings(reltol=1e-6, abstol=1e-6, matvec_max=1000)}
129+
return {"settings": BICGSTABSettings(reltol=1e-12, abstol=1e-12, matvec_max=1000)}
130+
131+
132+
def _higher_order_bicgstab_tolerances(value_dtype):
133+
if value_dtype == torch.float32:
134+
return {
135+
"output": (1e-5, 1e-5),
136+
"grad": (5e-5, 5e-5),
137+
"hess": (5e-3, 5e-3),
138+
}
139+
return {
140+
"output": (1e-8, 1e-8),
141+
"grad": (1e-6, 1e-6),
142+
"hess": (5e-5, 5e-5),
143+
}
144+
145+
146+
def _settings_for_higher_order_solve(solve, value_dtype):
147+
if solve is linear_cg:
148+
from torchsparsegradutils.utils.linear_cg import LinearCGSettings
149+
150+
return {"settings": LinearCGSettings(cg_tolerance=1e-5, max_cg_iterations=1000)}
151+
if solve is minres:
152+
from torchsparsegradutils.utils.minres import MINRESSettings
153+
154+
tolerance = 1e-6 if value_dtype == torch.float32 else 1e-10
155+
return {"settings": MINRESSettings(minres_tolerance=tolerance, max_cg_iterations=1000)}
156+
return {}
157+
158+
159+
def _higher_order_spd_tolerances(value_dtype):
160+
if value_dtype == torch.float32:
161+
return {
162+
"grad": (1e-3, 1e-3),
163+
"hess": (5e-2, 5e-2),
164+
}
165+
return {
166+
"grad": (1e-5, 1e-5),
167+
"hess": (5e-4, 5e-4),
168+
}
169+
170+
45171
# Define Fixtures
46172

47173

@@ -260,3 +386,99 @@ def test_kwargs_with_different_solvers_same_matrix():
260386
# All solutions should be close to each other
261387
assert torch.allclose(X_cg, X_minres, atol=atol, rtol=rtol)
262388
assert torch.allclose(X_bicgstab, X_minres, atol=atol, rtol=rtol)
389+
390+
391+
@pytest.mark.parametrize("base_solve", [linear_cg, minres], ids=[solve_id(linear_cg), solve_id(minres)])
392+
def test_sparse_generic_solve_higher_order_create_graph_no_out_error(layout, base_solve, device, value_dtype):
393+
torch.manual_seed(0)
394+
395+
theta = torch.randn(8, dtype=value_dtype, device=device, requires_grad=True)
396+
A, _ = _make_differentiable_tridiag_spd(theta, layout)
397+
B = torch.randn(8, 2, dtype=value_dtype, device=device)
398+
399+
kwargs = _settings_for_higher_order_solve(base_solve, value_dtype)
400+
loss = sparse_generic_solve(A, B, solve=base_solve, transpose_solve=base_solve, **kwargs).sum()
401+
402+
grad_theta = torch.autograd.grad(loss, theta, create_graph=True)[0]
403+
404+
assert grad_theta.requires_grad
405+
assert torch.isfinite(grad_theta).all()
406+
407+
second = torch.autograd.grad(grad_theta.sum(), theta)[0]
408+
assert torch.isfinite(second).all()
409+
410+
411+
@pytest.mark.parametrize("base_solve", [linear_cg, minres], ids=[solve_id(linear_cg), solve_id(minres)])
412+
def test_sparse_generic_solve_higher_order_matches_dense_reference(layout, base_solve, device, value_dtype):
413+
torch.manual_seed(1)
414+
415+
theta_sparse = torch.randn(6, dtype=value_dtype, device=device, requires_grad=True)
416+
theta_dense = theta_sparse.detach().clone().requires_grad_()
417+
418+
B = torch.randn(6, 2, dtype=value_dtype, device=device)
419+
420+
A_sparse, _ = _make_differentiable_tridiag_spd(theta_sparse, layout)
421+
_, A_dense = _make_differentiable_tridiag_spd(theta_dense, torch.sparse_coo)
422+
423+
kwargs = _settings_for_higher_order_solve(base_solve, value_dtype)
424+
tolerances = _higher_order_spd_tolerances(value_dtype)
425+
426+
out_sparse = sparse_generic_solve(A_sparse, B, solve=base_solve, transpose_solve=base_solve, **kwargs)
427+
out_dense = torch.linalg.solve(A_dense, B)
428+
429+
loss_sparse = out_sparse.square().sum()
430+
loss_dense = out_dense.square().sum()
431+
432+
grad_sparse = torch.autograd.grad(loss_sparse, theta_sparse, create_graph=True)[0]
433+
grad_dense = torch.autograd.grad(loss_dense, theta_dense, create_graph=True)[0]
434+
435+
hess_vec_sparse = torch.autograd.grad(grad_sparse.sum(), theta_sparse)[0]
436+
hess_vec_dense = torch.autograd.grad(grad_dense.sum(), theta_dense)[0]
437+
438+
grad_atol, grad_rtol = tolerances["grad"]
439+
hess_atol, hess_rtol = tolerances["hess"]
440+
assert torch.allclose(grad_sparse, grad_dense, atol=grad_atol, rtol=grad_rtol)
441+
assert torch.allclose(hess_vec_sparse, hess_vec_dense, atol=hess_atol, rtol=hess_rtol)
442+
443+
444+
def test_sparse_generic_solve_higher_order_nonsymmetric_bicgstab_matches_dense_reference(layout, device, value_dtype):
445+
torch.manual_seed(2)
446+
447+
n = 6
448+
theta_sparse = torch.randn(3 * n - 2, dtype=value_dtype, device=device, requires_grad=True)
449+
theta_dense = theta_sparse.detach().clone().requires_grad_()
450+
451+
B = torch.randn(n, 2, dtype=value_dtype, device=device)
452+
453+
A_sparse, _ = _make_differentiable_nonsymmetric_tridiag(theta_sparse, layout)
454+
_, A_dense = _make_differentiable_nonsymmetric_tridiag(theta_dense, torch.sparse_coo)
455+
assert not torch.allclose(A_dense, A_dense.T)
456+
457+
kwargs = _bicgstab_higher_order_kwargs(value_dtype)
458+
tolerances = _higher_order_bicgstab_tolerances(value_dtype)
459+
460+
out_sparse = sparse_generic_solve(
461+
A_sparse,
462+
B,
463+
solve=bicgstab,
464+
transpose_solve=_bicgstab_transpose,
465+
**kwargs,
466+
)
467+
out_dense = torch.linalg.solve(A_dense, B)
468+
output_atol, output_rtol = tolerances["output"]
469+
assert torch.allclose(A_dense @ out_sparse, B, atol=output_atol, rtol=output_rtol)
470+
471+
loss_sparse = out_sparse.square().sum()
472+
loss_dense = out_dense.square().sum()
473+
474+
grad_sparse = torch.autograd.grad(loss_sparse, theta_sparse, create_graph=True)[0]
475+
grad_dense = torch.autograd.grad(loss_dense, theta_dense, create_graph=True)[0]
476+
477+
hess_vec_sparse = torch.autograd.grad(grad_sparse.sum(), theta_sparse)[0]
478+
hess_vec_dense = torch.autograd.grad(grad_dense.sum(), theta_dense)[0]
479+
480+
grad_atol, grad_rtol = tolerances["grad"]
481+
hess_atol, hess_rtol = tolerances["hess"]
482+
assert torch.allclose(out_sparse, out_dense, atol=output_atol, rtol=output_rtol)
483+
assert torch.allclose(grad_sparse, grad_dense, atol=grad_atol, rtol=grad_rtol)
484+
assert torch.allclose(hess_vec_sparse, hess_vec_dense, atol=hess_atol, rtol=hess_rtol)

0 commit comments

Comments
 (0)