Feature sparse linalg solvers - #2841
abagusetty wants to merge 171 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
…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.
Co-authored-by: Anton <100830759+antonwolfy@users.noreply.github.com>
…e bug A prior commit accidentally deleted the entire rmatvec/rmatmat/adjoint/ transpose subsystem from _interface.py while addressing unrelated review comments. Restore it, keeping the two comments that did target this file: - LinearOperator.__init__: validate _isshape(shape) before int() truncation, so non-integer shapes like (3.7, 3.2) raise instead of silently rounding. - MatrixLinearOperator._matvec: unify sparse/dense branches to self.A.dot(x), since dpnp.ndarray.dot already dispatches to gemv. Also fix _ScaledLinearOperator: alpha_dtype used type(alpha) for plain Python scalars, forcing strong dtype promotion (float64/complex128) even when the operator was float32/complex64. This crashed LinearOperator.__init__'s dpnp.empty(dtype=float64) probe on fp64-less devices (e.g. Iris Xe). Promote weakly from the scalar value instead, matching array-API semantics. Verified: dpnp/tests/test_scipy_sparse_linalg.py 393 passed, on both the default device and ONEAPI_DEVICE_SELECTOR=opencl:cpu.
Co-authored-by: Anton <100830759+antonwolfy@users.noreply.github.com>
- clang-format: rewrap mkl_sparse::spmv calls in sparse/gemv.cpp - gersemi: reformat execute_process COMMAND block in CMakeLists.txt - pylint R0904: disable too-many-public-methods on csr_matrix - pylint C0301: wrap 81-char comment in gmres
| dtype=None, | ||
| copy=False, | ||
| *, | ||
| device=None, |
There was a problem hiding this comment.
We need to pass device, usm_type and sycl_queue to _init_from_components and _init_from_dense and use in asarray calls
| * Python MUST pass this back to sparse_gemv_compute so | ||
| * the C++ layer can verify that x and y dtype match the | ||
| * handle's value type. | ||
| * - event: dependency event the caller must wait on (or chain |
There was a problem hiding this comment.
We need to clarify here that the oneMath init event is empty and first-compute ordering comes from compute's depends.
| { | ||
| auto *cache = reinterpret_cast<SpmvCache *>(handle_ptr); | ||
|
|
||
| // Stored in the cache, not on the stack: spmv reads them after this |
There was a problem hiding this comment.
matvecs with different alpha/beta might race. Concretely — on one handle:
- host writes alpha = 2, submits spmv#1 (kernel not yet run)
- host writes alpha = 3, submits spmv#2
- spmv#1 kernel finally runs, dereferences &cache->alpha → reads 3, computes the wrong result for matvec (1)
That can't happen today: every caller passes alpha=1.0, beta=0.0 (hardcoded in _csr.py dot), so the slot value never changes between call. The bug only appears if a future caller drives one handle with varying alpha/beta while prior matvecs are in flight.
To fix that we need to store alpha/beta per-call and keep them alive via the compute's host-task (same lifetime trick used for x/y), instead of a single cache slot.
Or at least we have to add a comment that a handle must not be driven with differing alpha/beta while earlier matvecs are still in flight. That might be enough as for now.
| mkl_sparse::init_dense_vector(exec_q, &cache->x, op_cols, values); | ||
| mkl_sparse::init_dense_vector(exec_q, &cache->y, op_rows, values); |
There was a problem hiding this comment.
We need to add a comment that the compute-time set_dense_vector_data rebind is load-bearing and the optimize step must remain deferred.
Otherwise it's unclear why it's safe that both transiently point at values, which has only nnz elements, because
every gemv_compute_impl rebinds them to the real buffers before any SpMV runs.
| A = cupy.scipy.sparse.linalg.LinearOperator( | ||
| (n, n), | ||
| matvec=lambda v: v, | ||
| dtype=cupy.float64, |
There was a problem hiding this comment.
we need to use here
| dtype=cupy.float64, | |
| dtype=cupy.default_float_type(), |
and below
| return b | ||
|
|
||
|
|
||
| class TestLinearOperator(unittest.TestCase): |
There was a problem hiding this comment.
It seems the content of tests do not match what is now present in CuPy.
The scope of third party tests is intented to check parity with CuPy and should not be extended with additional tests.
Any new test should be places in dedicated dpnp own tests scope like dpnp/tests/test_scipy_sparse_linalg.py.
The Conda package workflow builds with rattler-build using conda-recipe/rattler_recipe.yaml, which was missing the onemkl-sycl-sparse run dependency (present only in conda-recipe/meta.yaml). Without it libmkl_sycl_sparse.so.6 is absent at runtime and importing the sparse SpMV extension fails.
Adds support for
from dpnp.scipy.sparse.linalg import LinearOperator, cg, gmres, minresFixes: #2831