Repository: scipy/scipy

Issue:
BUG: spsolve_triangular is way slower than spsolve
### Describe your issue.

Hi! 
I used `spsolve_trianguar` in a project and noticed that is is really slow. Then I compared it to the generic `spsolve` method and this one is indeed a lot faster. I created a mini program that simply solves `Ax=b` for an identity matrix `A` and a zero vector `b`

For `n=1000000` the measured time (on a single CPU core) is:
```
spsolve_triangular: 21.094783067703247 seconds
spsolve: 0.34332990646362305 seconds
```
On my more advanced triangular matrix the time difference is also in this range. Note that it includes the decomposition steps done in `spsolve`.

### Reproducing Code Example

```python
from scipy.sparse.linalg import spsolve
from scipy.sparse.linalg import spsolve_triangular

import numpy as np

import scipy
import time

n = 1000000

# create a sparse identity matrix and zero vector
I = scipy.sparse.identity(n).tocsr()
b = np.zeros(n)

#compare spsolve_triangular with spsolve

start = time.time()
x = spsolve_triangular(I, b)
stop = time.time()

print( "spsolve_triangular:",stop-start,"seconds")

start = time.time()
x = spsolve(I, b)
stop = time.time()

print( "spsolve:",stop-start,"seconds")
```


### Error message

```shell
None. But the generic algorithm is much faster than the specialized one. This should not happen.
```


### SciPy/NumPy/Python version information

1.7.3 1.21.5 sys.version_info(major=3, minor=10, micro=4, releaselevel='final', serial=0)


Discussion:
~~I think this is a duplicate of gh-10722. Do you agree @pjaap?~~
Never mind! This is the sparse version of that issue.

Resolving patch:
diff --git a/benchmarks/benchmarks/sparse_linalg_spsolve_triangular.py b/benchmarks/benchmarks/sparse_linalg_spsolve_triangular.py
new file mode 100644
--- /dev/null
+++ b/benchmarks/benchmarks/sparse_linalg_spsolve_triangular.py
@@ -0,0 +1,47 @@
+"""
+Check the speed of the sparse triangular solve function.
+"""
+import numpy as np
+from numpy.testing import assert_equal
+
+from .common import Benchmark, safe_import
+
+with safe_import():
+    from scipy import sparse
+    from scipy.sparse.linalg import spsolve, spsolve_triangular
+
+def _create_sparse_poisson1d(n):
+    # Make Gilbert Strang's favorite matrix
+    # http://www-math.mit.edu/~gs/PIX/cupcakematrix.jpg
+    # and take the lower triangular half
+    P1d = sparse.diags([[-1]*(n-1), [2]*n, [-1]*(n-1)], [-1, 0, 1])
+    assert_equal(P1d.shape, (n, n))
+    return P1d
+
+
+def _create_sparse_poisson2d_half(n):
+    P1d = _create_sparse_poisson1d(n)
+    P2d = sparse.kronsum(P1d, P1d)
+    assert_equal(P2d.shape, (n*n, n*n))
+    return sparse.tril(P2d).tocsr()
+
+
+class Bench(Benchmark):
+    params = [
+        [100,1000],
+        ["spsolve", "spsolve_triangular"],
+    ]
+    param_names = ['(n,n)',"method"]
+
+    def setup(self, n, method):
+        self.b = np.ones(n*n)
+        self.P_sparse = _create_sparse_poisson2d_half(n)
+
+    def time_solve(self, n, method):
+        if method == "spsolve":
+            spsolve(self.P_sparse, self.b)
+        elif method == "spsolve_triangular":
+            spsolve_triangular(self.P_sparse, self.b)
+        else:
+            raise NotImplementedError()
+
diff --git a/scipy/sparse/linalg/_dsolve/linsolve.py b/scipy/sparse/linalg/_dsolve/linsolve.py
--- a/scipy/sparse/linalg/_dsolve/linsolve.py
+++ b/scipy/sparse/linalg/_dsolve/linsolve.py
@@ -1,9 +1,9 @@
-from warnings import warn
+from warnings import warn, catch_warnings, simplefilter
 
 import numpy as np
 from numpy import asarray
 from scipy.sparse import (issparse,
-                          SparseEfficiencyWarning, csc_matrix, csr_matrix)
+                          SparseEfficiencyWarning, csc_matrix, eye, diags)
 from scipy.sparse._sputils import is_pydata_spmatrix, convert_pydata_sparse_to_scipy
 from scipy.linalg import LinAlgError
 import copy
@@ -611,15 +611,14 @@ def spsolve_triangular(A, b, lower=True, overwrite_A=False, overwrite_b=False,
     Parameters
     ----------
     A : (M, M) sparse matrix
-        A sparse square triangular matrix. Should be in CSR format.
+        A sparse square triangular matrix. Should be in CSR or CSC format.
     b : (M,) or (M, N) array_like
         Right-hand side matrix in ``A x = b``
     lower : bool, optional
         Whether `A` is a lower or upper triangular matrix.
         Default is lower triangular matrix.
     overwrite_A : bool, optional
-        Allow changing `A`. The indices of `A` are going to be sorted and zero
-        entries are going to be removed.
+        Allow changing `A`.
         Enabling gives a performance gain. Default is False.
     overwrite_b : bool, optional
         Allow overwriting data in `b`.
@@ -627,8 +626,7 @@ def spsolve_triangular(A, b, lower=True, overwrite_A=False, overwrite_b=False,
         If `overwrite_b` is True, it should be ensured that
         `b` has an appropriate dtype to be able to store the result.
     unit_diagonal : bool, optional
-        If True, diagonal elements of `a` are assumed to be 1 and will not be
-        referenced.
+        If True, diagonal elements of `a` are assumed to be 1.
 
         .. versionadded:: 1.4.0
 
@@ -652,9 +650,9 @@ def spsolve_triangular(A, b, lower=True, overwrite_A=False, overwrite_b=False,
     Examples
     --------
     >>> import numpy as np
-    >>> from scipy.sparse import csr_matrix
+    >>> from scipy.sparse import csc_array
     >>> from scipy.sparse.linalg import spsolve_triangular
-    >>> A = csr_matrix([[3, 0, 0], [1, -1, 0], [2, 0, 1]], dtype=float)
+    >>> A = csc_array([[3, 0, 0], [1, -1, 0], [2, 0, 1]], dtype=float)
     >>> B = np.array([[2, 0], [-1, 0], [2, 0]], dtype=float)
     >>> x = spsolve_triangular(A, B)
     >>> np.allclose(A.dot(x), B)
@@ -662,20 +660,42 @@ def spsolve_triangular(A, b, lower=True, overwrite_A=False, overwrite_b=False,
     """
 
     if is_pydata_spmatrix(A):
-        A = A.to_scipy_sparse().tocsr()
-
-    # Check the input for correct type and format.
-    if not (issparse(A) and A.format == "csr"):
-        warn('CSR matrix format is required. Converting to CSR matrix.',
+        A = A.to_scipy_sparse().tocsc()
+    
+    trans = "N"
+    if issparse(A) and A.format == "csr":
+        A = A.T
+        trans = "T"
+        lower = not lower
+
+    if not (issparse(A) and A.format == "csc"): 
+        warn('CSC or CSR matrix format is required. Converting to CSC matrix.',
              SparseEfficiencyWarning, stacklevel=2)
-        A = csr_matrix(A)
+        A = csc_matrix(A)
     elif not overwrite_A:
         A = A.copy()
 
-    if A.shape[0] != A.shape[1]:
+
+    M, N = A.shape
+    if M != N:
         raise ValueError(
             f'A must be a square matrix but its shape is {A.shape}.')
 
+    if unit_diagonal:
+        with catch_warnings():
+            simplefilter('ignore', SparseEfficiencyWarning)
+            A.setdiag(1)
+    else:
+        diag = A.diagonal()
+        if np.any(diag == 0):
+            raise LinAlgError(
+                'A is singular: zero entry on diagonal.')
+        invdiag = 1/diag
+        if trans == "N":
+            A = A @ diags(invdiag)
+        else:
+            A = (A.T @ diags(invdiag)).T
+
     # sum duplicates for non-canonical format
     A.sum_duplicates()
 
@@ -684,63 +704,39 @@ def spsolve_triangular(A, b, lower=True, overwrite_A=False, overwrite_b=False,
     if b.ndim not in [1, 2]:
         raise ValueError(
             f'b must have 1 or 2 dims but its shape is {b.shape}.')
-    if A.shape[0] != b.shape[0]:
+    if M != b.shape[0]:
         raise ValueError(
             'The size of the dimensions of A must be equal to '
             'the size of the first dimension of b but the shape of A is '
             f'{A.shape} and the shape of b is {b.shape}.'
         )
 
-    # Init x as (a copy of) b.
-    x_dtype = np.result_type(A.data, b, np.float64)
-    if overwrite_b:
-        if np.can_cast(b.dtype, x_dtype, casting='same_kind'):
-            x = b
-        else:
-            raise ValueError(
-                f'Cannot overwrite b (dtype {b.dtype}) with result '
-                f'of type {x_dtype}.'
-            )
-    else:
-        x = b.astype(x_dtype, copy=True)
+    result_dtype = np.promote_types(np.promote_types(A.dtype, np.float32), b.dtype)
+    if A.dtype != result_dtype:
+        A = A.astype(result_dtype)
+    if b.dtype != result_dtype:
+        b = b.astype(result_dtype)
+    elif not overwrite_b:
+        b = b.copy()
 
-    # Choose forward or backward order.
     if lower:
-        row_indices = range(len(b))
+        L = A
+        U = csc_matrix((N, N), dtype=result_dtype)
     else:
-        row_indices = range(len(b) - 1, -1, -1)
-
-    # Fill x iteratively.
-    for i in row_indices:
-
-        # Get indices for i-th row.
-        indptr_start = A.indptr[i]
-        indptr_stop = A.indptr[i + 1]
+        L = eye(N, dtype=result_dtype, format='csc')
+        U = A
+        U.setdiag(0)
 
-        if lower:
-            A_diagonal_index_row_i = indptr_stop - 1
-            A_off_diagonal_indices_row_i = slice(indptr_start, indptr_stop - 1)
-        else:
-            A_diagonal_index_row_i = indptr_start
-            A_off_diagonal_indices_row_i = slice(indptr_start + 1, indptr_stop)
-
-        # Check regularity and triangularity of A.
-        if not unit_diagonal and (indptr_stop <= indptr_start
-                                  or A.indices[A_diagonal_index_row_i] < i):
-            raise LinAlgError(
-                f'A is singular: diagonal {i} is zero.')
-        if not unit_diagonal and A.indices[A_diagonal_index_row_i] > i:
-            raise LinAlgError(
-                'A is not triangular: A[{}, {}] is nonzero.'
-                ''.format(i, A.indices[A_diagonal_index_row_i]))
-
-        # Incorporate off-diagonal entries.
-        A_column_indices_in_row_i = A.indices[A_off_diagonal_indices_row_i]
-        A_values_in_row_i = A.data[A_off_diagonal_indices_row_i]
-        x[i] -= np.dot(x[A_column_indices_in_row_i].T, A_values_in_row_i)
+    x, info = _superlu.gstrs(trans,
+                             N, L.nnz, L.data, L.indices, L.indptr,
+                             N, U.nnz, U.data, U.indices, U.indptr,
+                             b)
+    if info:
+        raise LinAlgError('A is singular.')
 
-        # Compute i-th entry of x.
-        if not unit_diagonal:
-            x[i] /= A.data[A_diagonal_index_row_i]
+    if not unit_diagonal:
+        invdiag = invdiag.reshape(-1, *([1] * (len(x.shape) - 1)))
+        x = x * invdiag
 
     return x
+


Test patch:
diff --git a/scipy/_lib/tests/test_warnings.py b/scipy/_lib/tests/test_warnings.py
--- a/scipy/_lib/tests/test_warnings.py
+++ b/scipy/_lib/tests/test_warnings.py
@@ -119,6 +119,7 @@ def test_warning_calls_filters(warning_calls):
         os.path.join('stats', '_continuous_distns.py'),
         os.path.join('stats', '_binned_statistic.py'),  # gh-19345
         os.path.join('_lib', '_util.py'),  # gh-19341
+        os.path.join('sparse', 'linalg', '_dsolve', 'linsolve.py'), # gh-17924
         "conftest.py",
     )
     bad_filters = [item for item in bad_filters if item.split(':')[0] not in
diff --git a/scipy/sparse/linalg/_dsolve/tests/test_linsolve.py b/scipy/sparse/linalg/_dsolve/tests/test_linsolve.py
--- a/scipy/sparse/linalg/_dsolve/tests/test_linsolve.py
+++ b/scipy/sparse/linalg/_dsolve/tests/test_linsolve.py
@@ -722,17 +722,59 @@ def worker():
 
         assert_equal(len(oks), 20)
 
+class TestGstrsErrors:
+    def setup_method(self):
+      self.A = array([[1.0,2.0,3.0],[4.0,5.0,6.0],[7.0,8.0,9.0]], dtype=np.float64)
+      self.b = np.array([[1.0],[2.0],[3.0]], dtype=np.float64)
+
+    def test_trans(self):
+        L = scipy.sparse.tril(self.A, format='csc')
+        U = scipy.sparse.triu(self.A, k=1, format='csc')
+        with assert_raises(ValueError, match="trans must be N, T, or H"):
+            _superlu.gstrs('X', L.shape[0], L.nnz, L.data, L.indices, L.indptr,
+                                U.shape[0], U.nnz, U.data, U.indices, U.indptr, self.b)
+
+    def test_shape_LU(self):
+        L = scipy.sparse.tril(self.A[0:2,0:2], format='csc')
+        U = scipy.sparse.triu(self.A, k=1, format='csc')
+        with assert_raises(ValueError, match="L and U must have the same dimension"):
+            _superlu.gstrs('N', L.shape[0], L.nnz, L.data, L.indices, L.indptr,
+                                U.shape[0], U.nnz, U.data, U.indices, U.indptr, self.b)
+
+    def test_shape_b(self):
+        L = scipy.sparse.tril(self.A, format='csc')
+        U = scipy.sparse.triu(self.A, k=1, format='csc')
+        with assert_raises(ValueError, match="right hand side array has invalid shape"):
+            _superlu.gstrs('N', L.shape[0], L.nnz, L.data, L.indices, L.indptr,
+                                U.shape[0], U.nnz, U.data, U.indices, U.indptr, 
+                                self.b[0:2])
+
+    def test_types_differ(self):
+        L = scipy.sparse.tril(self.A.astype(np.float32), format='csc')
+        U = scipy.sparse.triu(self.A, k=1, format='csc')
+        with assert_raises(TypeError, match="nzvals types of L and U differ"):
+            _superlu.gstrs('N', L.shape[0], L.nnz, L.data, L.indices, L.indptr,
+                                U.shape[0], U.nnz, U.data, U.indices, U.indptr, self.b)
+
+    def test_types_unsupported(self):
+        L = scipy.sparse.tril(self.A.astype(np.uint8), format='csc')
+        U = scipy.sparse.triu(self.A.astype(np.uint8), k=1, format='csc')
+        with assert_raises(TypeError, match="nzvals is not of a type supported"):
+            _superlu.gstrs('N', L.shape[0], L.nnz, L.data, L.indices, L.indptr,
+                                U.shape[0], U.nnz, U.data, U.indices, U.indptr, 
+                                self.b.astype(np.uint8))
 
 class TestSpsolveTriangular:
     def setup_method(self):
         use_solver(useUmfpack=False)
 
-    def test_zero_diagonal(self):
+    @pytest.mark.parametrize("fmt",["csr","csc"])
+    def test_zero_diagonal(self,fmt):
         n = 5
         rng = np.random.default_rng(43876432987)
         A = rng.standard_normal((n, n))
         b = np.arange(n)
-        A = scipy.sparse.tril(A, k=0, format='csr')
+        A = scipy.sparse.tril(A, k=0, format=fmt)
 
         x = spsolve_triangular(A, b, unit_diagonal=True, lower=True)
 
@@ -743,12 +785,16 @@ def test_zero_diagonal(self):
         A = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0]], dtype=np.float64)
         b = np.array([1., 2., 3.])
         with suppress_warnings() as sup:
-            sup.filter(SparseEfficiencyWarning, "CSR matrix format is")
+            sup.filter(SparseEfficiencyWarning, "CSC or CSR matrix format is")
             spsolve_triangular(A, b, unit_diagonal=True)
 
-    def test_singular(self):
+    @pytest.mark.parametrize("fmt",["csr","csc"])
+    def test_singular(self,fmt):
         n = 5
-        A = csr_matrix((n, n))
+        if fmt == "csr":
+            A = csr_matrix((n, n))
+        else:
+            A = csc_matrix((n, n))
         b = np.arange(n)
         for lower in (True, False):
             assert_raises(scipy.linalg.LinAlgError,
@@ -774,32 +820,53 @@ def test_input_types(self):
             assert_array_almost_equal(A.dot(x), b)
 
     @pytest.mark.slow
-    @pytest.mark.timeout(120)  # prerelease_deps_coverage_64bit_blas job
     @sup_sparse_efficiency
-    def test_random(self):
-        def random_triangle_matrix(n, lower=True):
-            A = scipy.sparse.random(n, n, density=0.1, format='coo')
+    @pytest.mark.parametrize("n", [10, 10**2, 10**3])
+    @pytest.mark.parametrize("m", [1, 10])
+    @pytest.mark.parametrize("lower", [True, False])
+    @pytest.mark.parametrize("format", ["csr", "csc"])
+    @pytest.mark.parametrize("unit_diagonal", [False, True])
+    @pytest.mark.parametrize("choice_of_A", ["real", "complex"])
+    @pytest.mark.parametrize("choice_of_b", ["floats", "ints", "complexints"])
+    def test_random(self, n, m, lower, format, unit_diagonal, choice_of_A, choice_of_b):
+        def random_triangle_matrix(n, lower=True, format="csr", choice_of_A="real"):
+            if choice_of_A == "real":
+                dtype = np.float64
+            elif choice_of_A == "complex":
+                dtype = np.complex128
+            else:
+                raise ValueError("choice_of_A must be 'real' or 'complex'.")
+            rng = np.random.default_rng(789002319)
+            rvs = rng.random
+            A = scipy.sparse.random(n, n, density=0.1, format='lil', dtype=dtype,
+                    random_state=rng, data_rvs=rvs)
             if lower:
-                A = scipy.sparse.tril(A)
+                A = scipy.sparse.tril(A, format="lil")
             else:
-                A = scipy.sparse.triu(A)
-            A = A.tocsr(copy=False)
+                A = scipy.sparse.triu(A, format="lil")
             for i in range(n):
                 A[i, i] = np.random.rand() + 1
+            if format == "csc":
+                A = A.tocsc(copy=False)
+            else:
+                A = A.tocsr(copy=False)
             return A
 
         np.random.seed(1234)
-        for lower in (True, False):
-            for n in (10, 10**2, 10**3):
-                A = random_triangle_matrix(n, lower=lower)
-                for m in (1, 10):
-                    for b in (np.random.rand(n, m),
-                              np.random.randint(-9, 9, (n, m)),
-                              np.random.randint(-9, 9, (n, m)) +
-                              np.random.randint(-9, 9, (n, m)) * 1j):
-                        x = spsolve_triangular(A, b, lower=lower)
-                        assert_array_almost_equal(A.dot(x), b)
-                        x = spsolve_triangular(A, b, lower=lower,
-                                               unit_diagonal=True)
-                        A.setdiag(1)
-                        assert_array_almost_equal(A.dot(x), b)
+        A = random_triangle_matrix(n, lower=lower)
+        if choice_of_b == "floats":
+            b = np.random.rand(n, m)
+        elif choice_of_b == "ints":
+            b = np.random.randint(-9, 9, (n, m))
+        elif choice_of_b == "complexints":
+            b = np.random.randint(-9, 9, (n, m)) + np.random.randint(-9, 9, (n, m)) * 1j
+        else:
+            raise ValueError(
+                "choice_of_b must be 'floats', 'ints', or 'complexints'.")
+        x = spsolve_triangular(A, b, lower=lower, unit_diagonal=unit_diagonal)
+        if unit_diagonal:
+            A.setdiag(1)
+        assert_allclose(A.dot(x), b, atol=1.5e-6)
+
+
+
diff --git a/scipy/sparse/tests/test_array_api.py b/scipy/sparse/tests/test_array_api.py
--- a/scipy/sparse/tests/test_array_api.py
+++ b/scipy/sparse/tests/test_array_api.py
@@ -256,13 +256,18 @@ def test_spsolve(B):
     )
 
 
-def test_spsolve_triangular():
-    X = scipy.sparse.csr_array([
+@pytest.mark.parametrize("fmt",["csr","csc"])
+def test_spsolve_triangular(fmt):
+    arr = [
         [1, 0, 0, 0],
         [2, 1, 0, 0],
         [3, 2, 1, 0],
         [4, 3, 2, 1],
-    ])
+    ]
+    if fmt == "csr":
+      X = scipy.sparse.csr_array(arr)
+    else:
+      X = scipy.sparse.csc_array(arr)
     spla.spsolve_triangular(X, [1, 2, 3, 4])