Repository: scipy/scipy

Issue:
Matrix multiplication in 0.14.x is more than 10x slower compared to 0.13.x
I'm working with very large sparse matrices (one with `shape=(1, 1153241)`, another with `shape=(1153241, 339131)`) and noticed that on Scipy v0.14.x and later, the matrix multiplication is much much slower compared to in Scipy v0.13.x. In my actual case with the sizes described above, v0.13.1 is faster than v0.14.0 by **more than 40x**.

I managed to reproduce this behaviour with smaller matrices, as shown in the timing and code below. Note that the v0.13.1 is faster than v0.14.0 by more than 10x. Although the difference is less due to the smaller matrices, but I hope this can serve as a good evidence that v0.14.0 is indeed significantly slower compared to v0.13.1.

Timing:

Scipy 0.13.1

```
Numpy version: 1.10.0.dev-31b94e8
Scipy version: 0.13.1
Loading matrix... Done in 0.025s
/data/lib/numpy/installed/lib/python2.7/site-packages/numpy/core/fromnumeric.py:2560: VisibleDeprecationWarning: `rank` is deprecated; use the `ndim` attribute or function instead. To find the rank of a matrix see `numpy.linalg.matrix_rank`.
  VisibleDeprecationWarning)
Doing multiplication... Done in 0.025s
```

Scipy 0.14.0

```
Numpy version: 1.10.0.dev-31b94e8
Scipy version: 0.14.0
Loading matrix... Done in 0.030s
Doing multiplication... Done in 0.305s
```

The code to reproduce this:

```
import os
import sys
sys.path.insert(0, '/data/lib/scipy/installed/lib/python2.7/site-packages')  # Put the path to scipy
sys.path.insert(0, '/data/lib/numpy/installed/lib/python2.7/site-packages')  # Put the path to numpy

import scipy
import numpy
from numpy import *
from scipy.sparse import *
import cPickle as pickle
import time

def test_sparse():
    H1, W1 = 1, 100000
    H2, W2 = W1, 1000
    C1 = 10
    C2 = 1000000

    print 'Numpy version: %s' % numpy.__version__
    print 'Scipy version: %s' % scipy.__version__

    filedir = os.path.abspath(os.path.dirname(__file__))
    random.seed(0)

    print 'Loading matrix...',
    start = time.time()
    if os.path.exists(filedir+'/matrix1.pickle'):
        with open(filedir+'/matrix1.pickle', 'rb') as infile:
            matrix1 = pickle.load(infile)
        with open(filedir+'/matrix2.pickle', 'rb') as infile:
            matrix2 = pickle.load(infile)
        end = time.time()
        print 'Done in %.3fs' % (end-start)
    else:
        matrix1 = lil_matrix(zeros((H1, W1)))
        matrix2 = lil_matrix(zeros((H2, W2)))
        for i in xrange(C1):
            matrix1[random.randint(H1), random.randint(W1)] = random.rand()
        for i in xrange(C2):
            matrix2[random.randint(H2), random.randint(W2)] = random.rand()
        matrix1 = matrix1.tocsr()
        matrix2 = matrix2.tocsr()
        end = time.time()
        print 'Done in %.3fs' % (end-start)
        with open(filedir+'/matrix1.pickle', 'wb') as outfile:
            pickle.dump(matrix1, outfile, protocol=2)
        with open(filedir+'/matrix2.pickle', 'wb') as outfile:
            pickle.dump(matrix2, outfile, protocol=2)

    print 'Doing multiplication...',
    start = time.time()
    for i in range(100):
        matrix3 = matrix1 * matrix2
    end = time.time()
    print 'Done in %.3fs' % (end-start)

test_sparse()
```

At first run the code will create random matrices with size similar to what I have in practice, then pickle it into two files (**TIPS**: create the matrix in v0.14.0, then load it later in v0.13.1, it's much faster with the new fancy indexing update in v0.14.0), so that subsequent runs will be faster, as shown above.

In the code the path to numpy is customizable because I was in the process of finding out whether it's numpy or scipy which causes the slowness. Changing numpy versions doesn't change the result, so Scipy v0.14.0 is indeed slower than Scipy v0.13.1.

I noticed that there are major changes in `scipy.sparse` in v0.14.x as the files in `scipy.sparse.sparsetools` are modified heavily, but I can't really understand the change. Can anyone explain what the changes were, and how can we improve the matrix multiplication performance on par with v0.13.x?



Discussion:
The cause of the problem are the calls to `foo.astype(idx_dtype)` in
scipy/sparse/compressed.py:_cs_matrix._mul_sparse_matrix.
.
Call to `astype()` makes an unnecessary copy of the data even if the
data type is already correct. The cast should be skipped if the data
type is already correct; this seems to restore the performance to the
previous level.

Note that this is a case where just copying the index arrays is more costly than actually evaluating the matrix product. In cases where the nnz of the resulting matrix is comparable to the nnz of the input matrices, there is probably no significant performance difference.

Should I work on this and submit a pull request?


Resolving patch:
diff --git a/scipy/sparse/benchmarks/bench_sparse.py b/scipy/sparse/benchmarks/bench_sparse.py
--- a/scipy/sparse/benchmarks/bench_sparse.py
+++ b/scipy/sparse/benchmarks/bench_sparse.py
@@ -5,11 +5,11 @@
 import warnings
 
 import numpy
-import numpy as np
-from numpy import ones, array, asarray, empty
+from numpy import ones, array, asarray, empty, random, zeros
 
-from numpy.testing import TestCase, run_module_suite
+from numpy.testing import Tester, TestCase
 
+import scipy
 from scipy import sparse
 from scipy.sparse import (csr_matrix, coo_matrix, dia_matrix, lil_matrix,
                           dok_matrix, rand, SparseEfficiencyWarning)
@@ -80,7 +80,7 @@ def bench_arithmetic(self):
             vars = dict([(var, mat.asformat(format)) for (var, _, mat) in matrices])
             for X,Y in [('A','A'),('A','B'),('B','A'),('B','B')]:
                 x,y = vars[X],vars[Y]
-                for op in ['__add__','__sub__','multiply','__div__','__mul__']:
+                for op in ['__add__','__sub__','multiply','__mul__']:
                     fn = getattr(x,op)
                     fn(y)  # warmup
 
@@ -302,11 +302,11 @@ def _getset_bench(self, kernel, formats):
                 i, j = [], []
                 while len(i) < N:
                     n = N - len(i)
-                    ip = np.random.randint(0, A.shape[0], size=n)
-                    jp = np.random.randint(0, A.shape[1], size=n)
-                    i = np.r_[i, ip]
-                    j = np.r_[j, jp]
-                v = np.random.rand(n)
+                    ip = numpy.random.randint(0, A.shape[0], size=n)
+                    jp = numpy.random.randint(0, A.shape[1], size=n)
+                    i = numpy.r_[i, ip]
+                    j = numpy.r_[j, jp]
+                v = numpy.random.rand(n)
 
                 if N == 1:
                     i = int(i)
@@ -363,32 +363,36 @@ def kernel(A, i, j, v=None):
         print('           Sparse Matrix fancy __getitem__')
         self._getset_bench(kernel, ['csr', 'csc', 'lil'])
 
-# class TestLarge(TestCase):
-#    def bench_large(self):
-#        # Create a 100x100 matrix with 100 non-zero elements
-#        # and play around with it
-#        #TODO move this out of Common since it doesn't use spmatrix
-#        random.seed(0)
-#        A = dok_matrix((100,100))
-#        for k in xrange(100):
-#            i = random.randrange(100)
-#            j = random.randrange(100)
-#            A[i,j] = 1.
-#        csr = A.tocsr()
-#        csc = A.tocsc()
-#        csc2 = csr.tocsc()
-#        coo = A.tocoo()
-#        csr2 = coo.tocsr()
-#        assert_array_equal(A.transpose().todense(), csr.transpose().todense())
-#        assert_array_equal(csc.todense(), csr.todense())
-#        assert_array_equal(csr.todense(), csr2.todense())
-#        assert_array_equal(csr2.todense().transpose(), coo.transpose().todense())
-#        assert_array_equal(csr2.todense(), csc2.todense())
-#        csr_plus_csc = csr + csc
-#        csc_plus_csr = csc + csr
-#        assert_array_equal(csr_plus_csc.todense(), (2*A).todense())
-#        assert_array_equal(csr_plus_csc.todense(), csc_plus_csr.todense())
+    def bench_large(self):
+        H1, W1 = 1, 100000
+        H2, W2 = W1, 1000
+        C1 = 10
+        C2 = 1000000
 
+        print()
+
+        random.seed(0)
+
+        print('                  Sparse Matrix Large Matrix Multiplication')
+        start = time.time()
+        matrix1 = lil_matrix(zeros((H1, W1)))
+        matrix2 = lil_matrix(zeros((H2, W2)))
+        for i in xrange(C1):
+            matrix1[random.randint(H1), random.randint(W1)] = random.rand()
+        for i in xrange(C2):
+            matrix2[random.randint(H2), random.randint(W2)] = random.rand()
+        matrix1 = matrix1.tocsr()
+        matrix2 = matrix2.tocsr()
+        end = time.time()
+
+        start = time.time()
+        for i in range(100):
+            matrix3 = matrix1 * matrix2
+        end = time.time()
+        print('==============================================================================')
+        print('Matrix 1 shape | Matrix 2 shape | Matrix 1 count | Matrix 2 count | time (sec)')
+        print('------------------------------------------------------------------------------')
+        print('%14s | %14s | %14d | %14d | %10.3f' % (matrix1.shape, matrix2.shape, C1, C2, end-start))
 
 if __name__ == "__main__":
-    run_module_suite()
+    test = Tester(__file__).bench()
diff --git a/scipy/sparse/compressed.py b/scipy/sparse/compressed.py
--- a/scipy/sparse/compressed.py
+++ b/scipy/sparse/compressed.py
@@ -83,7 +83,7 @@ def __init__(self, arg1, shape=None, dtype=None, copy=False):
                     self.shape = self._swap((major_dim,minor_dim))
 
         if dtype is not None:
-            self.data = self.data.astype(dtype)
+            self.data = np.asarray(self.data, dtype=dtype)
 
         self.check_format(full_check=False)
 
@@ -484,26 +484,26 @@ def _mul_sparse_matrix(self, other):
 
         fn = getattr(_sparsetools, self.format + '_matmat_pass1')
         fn(M, N,
-           self.indptr.astype(idx_dtype),
-           self.indices.astype(idx_dtype),
-           other.indptr.astype(idx_dtype),
-           other.indices.astype(idx_dtype),
+           np.asarray(self.indptr, dtype=idx_dtype),
+           np.asarray(self.indices, dtype=idx_dtype),
+           np.asarray(other.indptr, dtype=idx_dtype),
+           np.asarray(other.indices, dtype=idx_dtype),
            indptr)
 
         nnz = indptr[-1]
         idx_dtype = get_index_dtype((self.indptr, self.indices,
                                      other.indptr, other.indices),
                                     maxval=nnz)
-        indptr = indptr.astype(idx_dtype)
+        indptr = np.asarray(indptr, dtype=idx_dtype)
         indices = np.empty(nnz, dtype=idx_dtype)
         data = np.empty(nnz, dtype=upcast(self.dtype, other.dtype))
 
         fn = getattr(_sparsetools, self.format + '_matmat_pass2')
-        fn(M, N, self.indptr.astype(idx_dtype),
-           self.indices.astype(idx_dtype),
+        fn(M, N, np.asarray(self.indptr, dtype=idx_dtype),
+           np.asarray(self.indices, dtype=idx_dtype),
            self.data,
-           other.indptr.astype(idx_dtype),
-           other.indices.astype(idx_dtype),
+           np.asarray(other.indptr, dtype=idx_dtype),
+           np.asarray(other.indices, dtype=idx_dtype),
            other.data,
            indptr, indices, data)
 
@@ -705,8 +705,8 @@ def check_bounds(indices, bound):
         check_bounds(i, M)
         check_bounds(j, N)
 
-        i = i.astype(self.indices.dtype)
-        j = j.astype(self.indices.dtype)
+        i = np.asarray(i, dtype=self.indices.dtype)
+        j = np.asarray(j, dtype=self.indices.dtype)
 
         n_samples = len(x)
         offsets = np.empty(n_samples, dtype=self.indices.dtype)
@@ -758,12 +758,10 @@ def _insert_many(self, i, j, x):
         # Update index data type
         idx_dtype = get_index_dtype((self.indices, self.indptr),
                                     maxval=(self.indptr[-1] + x.size))
-        if idx_dtype != self.indptr.dtype:
-            self.indptr = self.indptr.astype(idx_dtype)
-            self.indices = self.indices.astype(idx_dtype)
-        if idx_dtype != i.dtype or idx_dtype != j.dtype:
-            i = i.astype(idx_dtype)
-            j = j.astype(idx_dtype)
+        self.indptr = np.asarray(self.indptr, dtype=idx_dtype)
+        self.indices = np.asarray(self.indices, dtype=idx_dtype)
+        i = np.asarray(i, dtype=idx_dtype)
+        j = np.asarray(j, dtype=idx_dtype)
 
         # Collate old and new in chunks by major index
         indices_parts = []
@@ -799,7 +797,7 @@ def _insert_many(self, i, j, x):
         # update attributes
         self.indices = np.concatenate(indices_parts)
         self.data = np.concatenate(data_parts)
-        nnzs = np.ediff1d(self.indptr, to_begin=0).astype(idx_dtype)
+        nnzs = np.asarray(np.ediff1d(self.indptr, to_begin=0), dtype=idx_dtype)
         nnzs[1:][ui] += new_nnzs
         self.indptr = np.cumsum(nnzs, out=nnzs)
 
@@ -1096,11 +1094,11 @@ def _binopt(self, other, op):
             data = np.empty(maxnnz, dtype=upcast(self.dtype, other.dtype))
 
         fn(self.shape[0], self.shape[1],
-           self.indptr.astype(idx_dtype),
-           self.indices.astype(idx_dtype),
+           np.asarray(self.indptr, dtype=idx_dtype),
+           np.asarray(self.indices, dtype=idx_dtype),
            self.data,
-           other.indptr.astype(idx_dtype),
-           other.indices.astype(idx_dtype),
+           np.asarray(other.indptr, dtype=idx_dtype),
+           np.asarray(other.indices, dtype=idx_dtype),
            other.data,
            indptr, indices, data)
 


Test patch: