Feature sparse linalg solvers - #2841
abagusetty wants to merge 162 commits into
Conversation
…oneMKL hooks
- _interface.py: add full operator algebra (.H, .T, +, *, **, neg),
_AdjointLinearOperator, _TransposedLinearOperator, _SumLinearOperator,
_ProductLinearOperator, _ScaledLinearOperator, _PowerLinearOperator,
IdentityOperator, MatrixLinearOperator, _AdjointMatrixOperator,
_CustomLinearOperator factory dispatch; extend aslinearoperator
to handle dpnp sparse and dense arrays
- _iterative.py: add _make_system (dtype validation, preconditioner
wiring, working dtype selection); add _make_fast_matvec CSR/oneMKL
SpMV hook; fix GMRES Arnoldi inner product to single oneMKL BLAS
gemv (dpnp.dot) instead of slow Python vdot loop; offload
Hessenberg lstsq to numpy.linalg.lstsq (CPU, matches CuPy);
fix SciPy host-fallback tol->rtol deprecation via _scipy_tol_kwarg;
add preconditioner support to CG; keep MINRES as SciPy-backed stub
Refs: CuPy v14.0.1 cupyx/scipy/sparse/linalg/_interface.py,
cupyx/scipy/sparse/linalg/_iterative.py"
…gmres, minres
Modeled after CuPy's cupyx_tests/scipy_tests/sparse_tests/test_linalg.py.
Covers:
- LinearOperator: shape, dtype inference, matvec/rmatvec/matmat,
subclassing, __matmul__, __call__, edge cases
- aslinearoperator: dense array, duck-type, identity passthrough,
rmatvec from dense, invalid inputs
- cg: SPD convergence, scipy reference match, x0 warm start, b_ndim=2,
callback, atol, LinearOperator path, invalid inputs,
non-convergence info check
- gmres: diag-dominant convergence, scipy reference match, restart
variants, x0, b_ndim=2, callbacks, complex systems, atol,
non-convergence info check, Hilbert-matrix stress test
- minres: SPD, symmetric-indefinite, scipy reference, shift parameter,
non-square guard, LinearOperator path, callback
- Integration: parametric (n, dtype) cross-solver tests via LinearOperator
- Import smoke tests: __all__ completeness
- Use dpnp.tests.helper: assert_dtype_allclose, generate_random_numpy_array, get_all_dtypes, get_float_complex_dtypes, has_support_aspect64 - Use dpnp.tests.third_party.cupy testing harness (with_requires, etc.) - Use numpy.testing assert_allclose / assert_array_equal / assert_raises - Use dpnp.asnumpy() instead of numpy.asarray() - Use pytest parametrize ids matching existing test conventions - Use is_scipy_available() helper from tests/helper.py - Strict class-per-solver organisation matching TestCholesky / TestDet etc.
…or dtype Two bugs fixed: 1. _init_dtype() was calling dpnp.zeros(n) which defaults to float64, so a float32 matvec would upcast and return float64, making the inferred dtype wrong. Fix: use dpnp.zeros(n, dtype=dpnp.int8) as SciPy/CuPy do — any numeric matvec will promote int8 to its own dtype. 2. _CustomLinearOperator.__init__ called _init_dtype() even when an explicit dtype was already supplied, overwriting the caller's value. Fix: _init_dtype() now short-circuits when self.dtype is already set.
…ption handling Align gemv.cpp with the conventions established in blas/gemm.cpp: Headers added: - ext/common.hpp (dpctl_td_ns, consistent with other extensions) - utils/memory_overlap.hpp (MemoryOverlap guard on x vs y) - utils/output_validation.hpp (CheckWritable + AmpleMemory on y) - utils/type_utils.hpp (validate_type_for_device<T> in impl) - <sstream> (needed for stringstream error_msg) Exception handling added in sparse_gemv_impl(): - try/catch(oneapi::mkl::exception) around all oneMKL sparse calls - try/catch(sycl::exception) around all oneMKL sparse calls - release_matrix_handle cleanup in the exception error path - throw std::runtime_error with descriptive message on catch Input validation added in sparse_gemv(): - ndim checks: x and y must be 1-D - queues_are_compatible() across all 5 USM arrays - MemoryOverlap()(x, y) aliasing guard - CheckWritable::throw_if_not_writable(y) - AmpleMemory::throw_if_not_ample(y, num_rows) - keep_args_alive() at function exit (was missing, returning empty event)
… table
Modeled after blas/gemm.cpp (2-D table: value type x index type) and
blas/gemv.cpp (dispatch vector pattern with ContigFactory + init_dispatch_table).
Changes:
- Add sparse/types_matrix.hpp with SparseGemvTypePairSupportFactory<Tv, Ti>
encoding the 4 supported combinations: {float32,float64} x {int32,int64}
- Rewrite sparse_gemv_impl() to take typeless char* pointers (matching
the blas gemv_impl signature style) — type info flows through template
params only, no runtime branching inside the impl
- Replace the 60-line if/else val_typenum/idx_typenum chain in sparse_gemv()
with a 2-D dispatch table lookup (gemv_dispatch_table[val_id][idx_id])
- Rename init_sparse_gemv_dispatch_vector -> init_sparse_gemv_dispatch_table
and implement it via init_dispatch_table<> from ext/common.hpp
- All validation guards and exception handling from prior commit are preserved
…se_gemv_dispatch_table Follows the rename made in gemv.cpp when the dispatch mechanism was changed from a 1-D vector to a 2-D table (value type x index type). All other declarations (sparse_gemv signature, parameters) are unchanged.
The oneMKL 2025-2 sparse BLAS API deprecated the old 8-argument
set_csr_data(queue, handle, nrows, ncols, index_base, row_ptr, col_ind,
values, deps) overload in favour of a new signature that takes the
sparse matrix handle as `spmat` and adds an explicit `nnz` argument:
set_csr_data(queue, spmat, nrows, ncols, nnz, index_base,
row_ptr, col_ind, values, deps)
Fixes:
- Replace old set_csr_data call with the new nnz-aware signature
- Silences the resulting -Wunused-parameter warning on `nnz` (now used)
- No functional change; all other logic is unchanged
…tring Line 477: `hasattr(A, "rmatmat\")` had a Markdown-escaped backslash leaked into the Python source, causing an unterminated string literal. Fixed to `hasattr(A, "rmatmat")`.
dpnp.ndarray blocks implicit NumPy conversion via __array__ to prevent silent dtype=object arrays. All test assertions must use .asnumpy() to materialize device arrays onto the host explicitly. Also replaces numpy.asarray(x_dp) in _rel_residual helper.
…dation order - _iterative.py: raise NotImplementedError for M != None *before* the _HOST_N_THRESHOLD SciPy fast-path in cg() and gmres(), so the contract is enforced regardless of system size (fixes test_cg_preconditioner_unsupported_raises, test_gmres_preconditioner_unsupported_raises). - _iterative.py: validate callback_type and raise NotImplementedError for 'pr_norm' *before* the _HOST_N_THRESHOLD branch in gmres(), so small-n systems also see the error (fixes test_gmres_callback_type_pr_norm_raises). - _iterative.py: pass callback_type='legacy' to scipy.sparse.linalg.gmres when delegating on the fast path to suppress SciPy DeprecationWarning. - test_scipy_sparse_linalg.py: add dtype=numpy.float64 to expected arange() calls in test_identity_operator and test_gmres_happy_breakdown so strict NumPy 2.0 dtype-equality checks pass (float64 result vs int64 expected).
… port SciPy corner cases
- Replace .asnumpy() method calls with dpnp.asnumpy() module fn (asnumpy is not an ndarray method in dpnp; it is a top-level fn) - Fix dpnp.any(x) ambiguous truth value in x0 zero-check; replace with explicit `x0 is not None` guard for r0 initialisation - Fix V_mat.T.conj() -> dpnp.conj(V_mat.T) in GMRES Arnoldi step - Guard minres beta sqrt against tiny negative floats: sqrt(abs(...)) - Unify GMRES Hessenberg h_np assignment to avoid .real stripping producing wrong dtype for complex systems - Fix float() cast on dpnp scalar norm inside GMRES inner h_j1 line
…failures) The committed code used hypot(gbar, oldb) as delta_k which is the gamma (norm) from the PREVIOUS rotation step, not the correct diagonal entry from applying the previous Givens rotation to the current column. The correct Paige-Saunders (1975) two-rotation recurrence is: oldeps = epsln delta = cs * dbar + sn * alpha # apply previous rotation gbar_k = sn * dbar - cs * alpha # residual -> new rotation input epsln = sn * beta dbar = -cs * beta gamma = hypot(gbar_k, beta) # NEW rotation eliminates beta cs = gbar_k / gamma sn = beta / gamma w_new = (v - oldeps*w - delta*w2) / gamma # three-term update This matches scipy.sparse.linalg.minres and Choi (2006) eq. 6.11. The buggy recurrence produced solutions ~1.08x away from the true solution (rel_err ~1e0) instead of the expected ~1e-13. Co-authored-by: fix-minres-recurrence
- _interface.py: LinearOperator.dot and aslinearoperator accept usm_ndarray via is_supported_array_type (keep numpy-reject message) - _interface.py: numpydoc for LinearOperator, matvec, rmatvec, matmat, rmatmat, dot, aslinearoperator (Parameters/Returns, scipy refs) - _iterative.py: scipy-style numpydoc for cg, gmres, minres - tests: cover aslinearoperator/matvec with usm_ndarray dense input
…agusetty/dpnp into feature-sparse-linalg-solvers
…pty-CSR dot Avoid .reshape (usm_ndarray lacks it), route sparse operators through 1-D SpMV, and short-circuit nnz==0 in csr_matrix.dot.
…agusetty/dpnp into feature-sparse-linalg-solvers
|
@antonwolfy Apologies for the delay in getting back to your comments. All of them were addressed, whenever you get a chance. Thanks again! |
…ate cg tol, raise on csr.T
…agusetty/dpnp into feature-sparse-linalg-solvers
|
|
||
| csr_matrix((M, N), [dtype=...]) | ||
| an empty (all-zero) matrix of shape ``(M, N)``; ``dtype`` | ||
| defaults to float64. |
There was a problem hiding this comment.
It should be a default floating point data type for the device on which the input data is allocated
| shape : tuple of int | ||
| dtype : dpnp dtype | ||
| nnz : int |
There was a problem hiding this comment.
Missing description of attributes
| Sparse linear algebra interface for DPNP. | ||
|
|
||
| This module provides a subset of :mod:`scipy.sparse.linalg` | ||
| functionality on top of DPNP arrays. |
There was a problem hiding this comment.
| functionality on top of DPNP arrays. | |
| functionality on top of DPNP arrays. |
| Duplicate column indices within a row are not supported (unlike | ||
| scipy, which sums them); each column must appear at most once per | ||
| row. This matches the CSR produced by dense construction and the | ||
| solvers, which never generate duplicates. | ||
|
|
||
| Supported operations: construction, ``dot`` (matvec) via cached | ||
| oneMKL SpMV, ``toarray``, ``copy``. This is a solver-support | ||
| subset of the scipy/cupy CSR API; arithmetic, indexing, reductions, | ||
| transpose, format conversion and element-wise math are not | ||
| implemented (the most common such methods raise | ||
| ``NotImplementedError``). Convert with ``toarray()`` and use dpnp | ||
| for those. |
There was a problem hiding this comment.
It seems that has to be moved after the attributes block, otherwise rendered badly
|
|
||
| def __init__(self, dtype, shape): | ||
| if dtype is not None: | ||
| dtype = dpnp.dtype(dtype) |
There was a problem hiding this comment.
to ensure dtype is supported:
| dtype = dpnp.dtype(dtype) | |
| dtype = dpnp.empty(0, dtype=dtype).dtype |
| self._shape = (nrows, ncols) | ||
| self._has_sorted_indices = True | ||
|
|
||
| def _init_from_components(self, arrays, shape, dtype=None, copy=False): |
There was a problem hiding this comment.
dpnp might pass 7 to oneMKL -> OOB read of length-3 x for below example:
sp.csr_matrix((np.array([1.,2.]), np.array([0,7],np.int32), np.array([0,1,2],np.int32)), shape=(2,3))
# Out: <2x3 csr_matrix of dtype float64 with 2 stored elements>while scipy:
a = sp.csr_matrix((np.array([1.,2.]), np.array([0,7],np.int32), np.array([0,1,2],np.int32)), shape=(2,3))
a.check_format()
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[9], line 1
----> 1 a.check_format()
File /localdisk/work/antonvol/miniforge3/envs/dpnp_dev/lib/python3.13/site-packages/scipy/sparse/_compressed.py:212, in _cs_matrix.check_format(self, full_check)
210 if self.nnz > 0:
211 if self.indices.max() >= N:
--> 212 raise ValueError(f"indices must be < {N}")
213 if self.indices.min() < 0:
214 raise ValueError("indices must be >= 0")
ValueError: indices must be < 3| self._shape = (nrows, ncols) | ||
| self._has_sorted_indices = True | ||
|
|
||
| def _init_from_components(self, arrays, shape, dtype=None, copy=False): |
There was a problem hiding this comment.
- scipy: silent at construction, ValueError in check_format
- dpnp: row_lengths = diff = [2,-1] -> negative count to dpnp.repeat / bad offsets to oneMKL
sp.csr_matrix((np.array([1.,2.,3.]), np.array([0,1,2],np.int32), np.array([0,2,1],np.int32)), shape=(2,3))There was a problem hiding this comment.
Fixed it to be similar like scipy. It is silent at construction but check_format runs the checks lazily on the first use now
| cache->descr, {ev_opt}); | ||
| } | ||
|
|
||
| return mkl_sparse::spmv(exec_q, mkl_trans, &alpha, cache->view, |
There was a problem hiding this comment.
alpha/beta passed by address of stack, because oneMath spmv takes const void* alpha and so might be destroyed when gemv_compute_impl returns
There was a problem hiding this comment.
Fixed all the instance of this!
| # No native SpMM: emulate as a column loop of 1-D SpMVs (no densify). | ||
| if issparse(self.A): | ||
| return dpnp.stack( | ||
| [self.A.dot(X[:, i]) for i in range(X.shape[1])], axis=-1 |
There was a problem hiding this comment.
self.A.dot(X[:, i]) on a C-contiguous 2-D X passes a non-unit-stride view.
sparse_gemv_compute only checks ndim/shape/dtype and reads x.get_data() contiguously → wrong results for L @ X/L.matmat(X) with ≥2 columns, no error.
There was a problem hiding this comment.
Fixed. Thanks for catching this. sparse_gemv_compute now rejects non-contiguous x/y rather than silently consuming it. Added few unit-tests to cover this!
| exec_q = self.data.sycl_queue | ||
| _manager = _dpu.SequentialOrderManager[exec_q] | ||
| # pylint: disable-next=protected-access | ||
| handle, val_type_id, ev = _si._sparse_gemv_init( |
There was a problem hiding this comment.
The below example raises exception here:
import dpnp as np
import dpnp.scipy.sparse as sp
b0 = np.zeros(4, dtype=np.float64)
A_empty = sp.csr_matrix((4, 4), dtype=np.float64)
sp.linalg.cg(A_empty, b0, rtol=1e-8)
---------------------------------------------------------------------------
RuntimeError Traceback (most recent call last)
Cell In[12], line 1
----> 1 sp.linalg.cg(A_empty, b0, rtol=1e-8)
File /localdisk/work/antonvol/code/dpnp/dpnp/scipy/sparse/linalg/_iterative.py:373, in cg(A, b, x0, rtol, tol, maxiter, M, callback, atol)
365 warnings.warn(
366 "'tol' is deprecated in favor of 'rtol' and will be removed in "
367 "a future release; use 'rtol' instead.",
368 DeprecationWarning,
369 stacklevel=2,
370 )
371 rtol = tol
--> 373 A_op, M_op, x, b, dtype = _make_system(A, M, x0, b)
374 n = b.shape[0]
376 bnrm = dpnp.linalg.norm(b)
File /localdisk/work/antonvol/code/dpnp/dpnp/scipy/sparse/linalg/_iterative.py:271, in _make_system(A, M, x0, b)
268 M_op = _FastMOp()
270 # Inject fast CSR SpMV for A if available.
--> 271 fast_mv = _make_fast_matvec(A)
272 if fast_mv is not None:
273 _orig = A_op
File /localdisk/work/antonvol/code/dpnp/dpnp/scipy/sparse/linalg/_iterative.py:178, in _make_fast_matvec(A)
176 return None
177 # pylint: disable-next=protected-access
--> 178 handle_info = A._ensure_spmv_handle()
179 if handle_info is None:
180 return None
File /localdisk/work/antonvol/code/dpnp/dpnp/scipy/sparse/_csr.py:445, in csr_matrix._ensure_spmv_handle(self)
443 _manager = _dpu.SequentialOrderManager[exec_q]
444 # pylint: disable-next=protected-access
--> 445 handle, val_type_id, ev = _si._sparse_gemv_init(
446 exec_q,
447 0, # trans=N (forward)
448 _dpnp.get_usm_ndarray(self.indptr),
449 _dpnp.get_usm_ndarray(self.indices),
450 _dpnp.get_usm_ndarray(self.data),
451 int(self._shape[0]),
452 int(self._shape[1]),
453 int(self.data.shape[0]),
454 _manager.submitted_events,
455 )
457 # set_csr_data + optimize_gemv must complete before the first
458 # compute; chain the init event through the queue's order manager
459 # so the first _sparse_gemv_compute depends on it (non-blocking).
460 _manager.add_event_pair(ev, ev)
RuntimeError: sparse_gemv_init: MKL exception in set_csr_data: oneapi::mkl::sparse::set_csr_data: invalid argument: nnz is = 0 provided with at least one of `ja`/`a` != nullptrbut passes with scipy
…nto feature-sparse-linalg-solvers
…agusetty/dpnp into feature-sparse-linalg-solvers
…nalg-solvers # Conflicts: # CHANGELOG.md
|
@antonwolfy Is there a timeline for the 0.21.0 release/code freeze. Just wanted to prioritize this PR accordingly on my end |
@abagusetty, as for now, we are planning the freeze by mid of September. |
Reading usm_ndarray data pointers after py::gil_scoped_release aborted the interpreter, since get_data() calls into the Python C-API; read both pointers before releasing the GIL, matching in_place.tpp.
| if dtypes is None: | ||
| dtypes = [] | ||
| for obj in operators: | ||
| if obj is not None and hasattr(obj, "dtype") and obj.dtype is not None: | ||
| dtypes.append(obj.dtype) | ||
| return dpnp.result_type(*dtypes) if dtypes else None |
There was a problem hiding this comment.
That causes tests failure on device without fp64:
FAILED tests/test_scipy_sparse_linalg.py::TestLinearOperatorAlgebra::test_scaled_linear_operator[float32] - ValueError: Device Intel(R) Iris(R) Xe Graphics does not provide native support for double-precision floating point type.
--
tests/test_scipy_sparse_linalg.py::TestLinearOperatorAlgebra::test_scaled_linear_operator[complex64] - ValueError: Device Intel(R) Iris(R) Xe Graphics does not provide native support for double-precision floating point type.
Co-authored-by: Anton <100830759+antonwolfy@users.noreply.github.com>
| } | ||
|
|
||
| std::pair<sycl::event, sycl::event> | ||
| sparse_gemv_compute(sycl::queue &exec_q, |
There was a problem hiding this comment.
It looks like non-contiguous inputs are not supported here.
sparse_gemv_compute reads x and y linearly but does not check that they are contiguous.
The same applies to row_ptr, col_ind and values in sparse_gemv_init
For strided inputs this can silently produce incorrect results instead of raising an error
In [1]: import dpnp
In [2]: A = dpnp.scipy.sparse.csr_matrix(dpnp.array([[1., 2., 0.], [0., 3., 4.], [5., 0., 6.]]))
In [3]: x = dpnp.arange(6, dtype='f8')[::2]
In [4]: A.dot(x)
Out[4]: array([ 2., 11., 12.])
# scipy
In [1]: import scipy
In [2]: import numpy
In [3]: A = scipy.sparse.csc_matrix(numpy.array([[1., 2., 0.], [0., 3., 4.], [5., 0., 6.]]))
In [4]: x = numpy.arange(6, dtype=numpy.float64)[::2]
In [5]: A.dot(x)
Out[5]: array([ 4., 22., 24.])
Also the current tests do not cover non-contiguous inputs
| self.data = data | ||
| self.indices = indices | ||
| self.indptr = indptr |
There was a problem hiding this comment.
Would it make sense to make them read-only properties?
The oneMKL handle is cached using pointers to these buffers but reassigning any of them does not invalidate the handle and may leave it pointing to freed USM memory
In [1]: import numpy
In [2]: import dpnp
In [3]: A = dpnp.scipy.sparse.csr_matrix(dpnp.array(numpy.diag([1., 2., 3.])))
In [4]: A.dot(dpnp.ones(3))
Out[4]: array([1., 2., 3.])
In [5]: A.data = dpnp.array([10.,20.,30.])
In [6]: A.dot(dpnp.ones(3))
Out[6]: array([1., 1., 1.])
#scipy
In [1]: import scipy
In [2]: import numpy
In [3]: A = scipy.sparse.csr_matrix(numpy.diag([1., 2., 3.]))
In [4]: A.dot(numpy.ones(3))
Out[4]: array([1., 2., 3.])
In [5]: A.data = numpy.array([10.,20.,30.])
In [6]: A.dot(numpy.ones(3))
Out[6]: array([10., 20., 30.])
| dtype = dpnp.complex128 | ||
| else: | ||
| dtype = dpnp.float64 | ||
|
|
There was a problem hiding this comment.
float64 and complex128 may not be supported on GPUs without fp64
| # Returns a handle cached on A, or None for an unsupported dtype. | ||
| if not hasattr(A, "_ensure_spmv_handle"): | ||
| return None | ||
| # pylint: disable-next=protected-access |
There was a problem hiding this comment.
oneMKL set_csr_data rejects nnz == 0 line 506 in _csr.py
Should we add the same guard here?
| if not _isshape(shape): | ||
| raise ValueError( | ||
| f"invalid shape {shape!r} (must be a length-2 tuple of " | ||
| "non-negative ints)" | ||
| ) |
There was a problem hiding this comment.
This check should be moved above shape = tuple(int(s) for s in shape)
Right now (3.7, 3.2) is converted to (3, 3) before _isshape() runs,so non-integer dimensions are silently accepted
In [5]: from dpnp.scipy.sparse.linalg import LinearOperator
In [6]: LinearOperator((3.7, 3.2), matvec=lambda v: v).shape
Out[6]: (3, 3)
# scipy
In [10]: from scipy.sparse.linalg import LinearOperator
In [11]: LinearOperator((3.7, 3.2), matvec=lambda v: v).shape
ValueError: invalid shape (3.7, 3.2)
| return dpnp.empty_like(b), 0 | ||
| b_norm = float(dpnp.linalg.norm(b)) | ||
| if b_norm == 0.0: | ||
| return b, 0 |
There was a problem hiding this comment.
| return b, 0 | |
| return dpnp.zeros_like(b), 0 |
Just like in def cg
| if issparse(self.A): | ||
| return self.A.dot(x) | ||
| return super()._matvec(x) |
There was a problem hiding this comment.
Would not it be simpler to just use self.A.dot(x) here for both branches?
dpnp.ndarray.dot with 1-D argument already dispatches to gemv
Adds support for
from dpnp.scipy.sparse.linalg import LinearOperator, cg, gmres, minresFixes: #2831