这可能会导致内存问题。在一些带有交换内存或大容量内存(例如64 GiB)的机器上,程序可能运行得更慢,并且仅分配几个GiB的内存可能会导致进程被杀死,如果内存关闭接近饱和(例如因为Linux上的OOM killer)。请注意,Windows在剩余内存非常有限(即ZRAM)时压缩RAM中的数据。这种方法对于具有许多零的数组效果很好。但是,如果稍后分配其他数组并从RAM中读回密集数组,则操作系统需要从RAM中解压缩数据,并且需要更多的空间。如果在解压缩过程中剩余的RAM内容不能被压缩得那么好,则很可能会发生内存不足。
x.astype("float16", copy=False)
这不可能将数组转换为具有不同数据类型的另一个数组,而无需复制它,特别是如果每个项目的大小在内存中不同。 即使可以这样做,这也没有意义,因为目标是减少输入的大小,以创建一个新数组,而不是保留初始数组。
# 增加了一些原因的内存要求,并死亡
这有两个原因。
首先,Scipy目前还不支持稀疏矩阵的float16数据类型。您可以通过检查sparse_x.dtype来验证这一点:当x为float16时,它是float32。这是因为大多数平台上本地不支持float16数据类型(x86-64处理器主要支持该类型的转换)。因此,CSR矩阵的data
部分比它本应该有的大小大了一倍。
其次,Numpy不支持在具有不同数据类型的数组上进行二进制操作。Numpy首先将二进制操作的输入转换为匹配的数据类型。为此,它遵循语义规则。这被称为类型提升。SciPy通常以相同的方式工作(特别是因为它经常在内部使用Numpy)。这意味着在您的情况下,int8数组很可能会隐式转换为float32
数组,即在内存中增加4倍的数组大小。我们需要深入了解SciPy的实现才能理解到底发生了什么。
揭秘幕后
让我们了解一下在SciPy中进行矩阵乘法时内部发生了什么。正如@hpaulj所指出的那样,sparse_x.dot(y)
调用了_mul_multivector
,它创建了输出数组并调用了csr_matvecs
。最后一个是一个C++封装函数,调用由宏SPTOOLS_CSR_DEFINE_TEMPLATE
实例化的模板函数。这个宏提供给另一个宏SPTOOLS_FOR_EACH_INDEX_DATA_TYPE_COMBINATION
,负责从预定义支持类型列表生成所有可能的实例。 csr_matvecs
的实现可以在这里找到。
基于这段代码,我们可以看到SciPy不支持float16
数据类型(至少对于稀疏矩阵而言)。我们还可以看到,在调用C++函数csr_matvecs
时,self
和other
必须具有相同的类型(在类似this one的包装代码中调用C++函数之前肯定会进行类型提升)。我们还可以看到,C++实现并不特别优化,也很简单。人们可以轻松编写具有相同逻辑和性能的代码,使用Numba或Cython来支持您的特定紧凑输入类型。最后,我们可以看到,在C++计算部分没有分配任何内容(仅在Python代码和C++包装器中分配)。
内存高效实现
首先,这里有一段代码可以以紧凑的方式设置数组:
from scipy import sparse as sps
import numpy as np
sparse_x = sps.random(m=10_000, n=600_000, density=0.1, dtype=np.float32)
sparse_x = sps.csr_matrix(sparse_x)
y = np.random.randint(0, 2, size=(600_000, 256), dtype=np.int8)
np.subtract(np.multiply(y, 2, out=y), 1, out=y)
一个简单的纯Numpy解决方案来减轻临时数组的开销是通过块计算矩阵乘法。以下是一个按带计算矩阵的示例:
chunk_count = 4
m, n = sparse_x.shape
p, q = y.shape
assert n == p
result = np.empty((m, q), dtype=np.float16)
for i in range(chunk_count):
start, end = m*i//chunk_count, m*(i+1)//chunk_count
result[start:end,:] = sparse_x[start:end,:] @ y
这个方法在我的机器上比原来的慢不到5%,并且由于每次只创建输出矩阵中一个带宽的临时数组,所以占用的内存更少。如果您仍然在使用此代码时遇到内存问题,请检查输出矩阵是否实际上可以适应RAM,使用以下代码进行检查:for l in result: l[:] = np.random.rand(l.size)
。事实上,创建一个数组并不意味着在RAM中保留了内存空间(请参见this文章)。
一种更节省内存且更快的解决方案是使用Numba或Cython,以便手动执行Scipy所做的操作而不创建任何临时数组。坏消息是Numba尚不支持float16
数据类型,因此需要使用float32
。以下是一种实现方式:
import numba as nb
@nb.njit(['(float32[::1], int32[::1], int32[::1], int8[:,::1])', '(float32[::1], int64[::1], int64[::1], int8[:,::1])'], fastmath=True, parallel=True)
def sparse_compute(x_data, x_cols, x_rows, y):
result = np.empty((x_rows.size-1, y.shape[1]), dtype=np.float32)
for i in nb.prange(x_rows.size-1):
line = np.zeros(y.shape[1], dtype=np.float32)
for j in range(x_rows[i], x_rows[i+1]):
factor = x_data[j]
y_line = y[x_cols[j],:]
for k in range(line.size):
line[k] += y_line[k] * factor
for k in range(line.size):
result[i, k] = line[k]
return result
z = sparse_compute(sparse_x.data, sparse_x.indices, sparse_x.indptr, y)
这比以前的解决方案快得多,同时它也消耗更少的内存。事实上,只需要10 MiB来计算结果(这比我的机器上最初的解决方案少了50-200倍),并且它在一台只有2个核心(i7-3520M)的10年老移动处理器上比SciPy快约3.5倍!
LIL稀疏矩阵的概括
上述Numba代码仅适用于CSR矩阵。LIL矩阵并不高效。它们被设计用于快速构建矩阵,但在计算方面效率低下。文档建议将它们转换为CSR/CSC矩阵(一旦创建)以进行计算。LIL矩阵内部是两个CPython列表对象的NumPy数组。由于CPython列表的设计方式,列表是低效的,并且不能通过Numba/Cython/C进行优化。它们还使用大量内存(COO对此肯定更好)。事实上,在主流的64位平台上,使用CPython时,每个列表项都是一个对象,而CPython对象通常占用32字节,更不用说在CPython列表中引用所占用的8字节了。每个非零值需要2个对象,因此每个非零值需要80字节。因此,生成的LIL输入矩阵非常庞大。
由于Numba实际上不支持CPython列表,因此可以将列表即时转换为NumPy数组,以便能够执行相对快速的内存高效计算。幸运的是,这种转换在大型矩阵上并不昂贵。以下是一种实现方法:
@nb.njit('(float32[::1], int32[::1], int8[:,::1], float32[::1])', fastmath=True)
def nb_compute_lil_row(row_data, row_idx, y, result):
assert row_data.size == row_idx.size
assert result.size == y.shape[1]
for i in range(row_data.size):
factor = row_data[i]
y_line = y[row_idx[i],:]
for k in range(y_line.size):
result[k] += y_line[k] * factor
def nb_compute_lil(sparse_x, y):
result = np.zeros((sparse_x.shape[0], y.shape[1]), dtype=np.float32)
for i in range(len(sparse_x.rows)):
row_data = np.fromiter(sparse_x.data[i], np.float32)
row_idx = np.fromiter(sparse_x.rows[i], np.int32)
nb_compute_lil_row(row_data, row_idx, y, result[i])
return result
z = nb_compute_lil(sparse_x, y)
这段代码在我的机器上比Scipy慢了两倍,但它消耗的内存更少。最差的性能是由于Numba在这种特定情况下无法生成快速的SIMD代码(由于内部LLVM-JIT编译器的优化不足)。
x
和y
是二维的时,x.dot(y)
就等同于x @ y
。而且根据你提供的密度和数组大小,这两个数组的乘积不会是稀疏的。稀疏矩阵中任何一行都全是零的概率几乎为零。 - Frank Yellinastype
改变大小时,copy=False
是无用的,因为它必须制作一份副本。同样,在从密集矩阵转换到稀疏矩阵时也是如此,它必须制作数据的副本。我不确定,但很可能csr
乘法代码是针对常见的C
类型(float和double)编译的。您可以测试没有内存问题的小示例来验证。Float * int 将导致 float,而不是小 int。 - hpaulj