稀疏矩阵和非稀疏NumPy矩阵之间的内积,需要具备高效的内存使用。

4

我已经查看了之前提出的类似问题(例如[1] [2]),但是它们都不完全符合我的问题。

我正在尝试计算两个大矩阵之间的点积,并且我必须满足一些内存限制。

我有一个 numpy 稀疏矩阵,其形状为 (10000,600000)。例如,

from scipy import sparse as sps
x = sps.random(m=10000, n=600000, density=0.1).toarray()

第二个numpy矩阵的大小为(600000,256),仅包含(-1,1)。
import numpy as np
y = np.random.choice([-1,1], size=(600000, 256))

我需要以最低可能的内存要求计算xy的点积。速度不是主要问题。

到目前为止,我尝试了以下方法:

Scipy稀疏格式:

自然地,我将numpy稀疏矩阵转换为scipy csr_matrix。然而,由于内存问题,任务仍然被终止。没有错误,我只是在终端上被终止了。

from scipy import sparse as sps
sparse_x = sps.csr_matrix(x, copy=False)
z = sparse_x.dot(y)
# killed

减少dtype精度+Scipy稀疏格式:

from scipy import sparse as sps

x = x.astype("float16", copy=False)
y = y.astype("int8", copy=False)

sparse_x = sps.csr_matrix(x, copy=False)
z = sparse_x.dot(y)
# Increases the memory requirement for some reason and dies 

np.einsum

不确定它是否适用于稀疏矩阵。在这个答案中发现了一些有趣的东西。然而,以下内容也没有帮助:

z = np.einsum('ij,jk->ik', x, y)
# similar memory requirement as the scipy sparse dot

有什么建议吗?

如果您有任何建议来改进这些内容,请告诉我。此外,我正在考虑以下方向:

  1. 如果可以以某种方式摆脱点积本身,那将是很好的。我的第二个矩阵(即y)是随机生成的,它只有[-1, 1]。我希望能够利用其特性。

  2. 可能将点积分成几个小的点积,然后聚合。


4
xy是二维的时,x.dot(y)就等同于x @ y。而且根据你提供的密度和数组大小,这两个数组的乘积不会是稀疏的。稀疏矩阵中任何一行都全是零的概率几乎为零。 - Frank Yellin
3
这个有多少最大可用内存?首先,我们需要评估这是否可行。 - Michael Ruth
1
产品可以通过许多方式进行近似。其中一种方法是通过抽样来描述,具体请参见此处。另一种近似技术是由LM-BFGS使用的,它考虑了与问题相关的信息。 - Michael Ruth
1
当使用 astype 改变大小时,copy=False 是无用的,因为它必须制作一份副本。同样,在从密集矩阵转换到稀疏矩阵时也是如此,它必须制作数据的副本。我不确定,但很可能 csr 乘法代码是针对常见的 C 类型(float和double)编译的。您可以测试没有内存问题的小示例来验证。Float * int 将导致 float,而不是小 int。 - hpaulj
1
您可以使用MKL稀疏函数将稀疏数组和密集数组直接相乘到预分配的密集数组中,而且还有一个Python包装器可供使用。 - CJR
显示剩余13条评论
3个回答

4

简而言之:由于临时数组、类型提升和低效使用,SciPy消耗的内存比实际需要的要多得多。它也不是很快。可以优化设置以使用更少的内存,并使用Numba来高效地执行计算(无论是内存使用还是时间)。


我有一个numpy稀疏矩阵,它的形状是(10000,600000)。例如,
x = sps.random(m=10000, n=600000, density=0.1).toarray()
sps.random(...)是一个COO稀疏矩阵,因此在内存中相对紧凑。使用.toarray()将其转换为巨大的密集矩阵。实际上,这个结果密集矩阵(x)需要10000*600000*8 = 44.7 GiB (因为浮点数的默认类型是64位宽)。

这可能会导致内存问题。在一些带有交换内存或大容量内存(例如64 GiB)的机器上,程序可能运行得更慢,并且仅分配几个GiB的内存可能会导致进程被杀死,如果内存关闭接近饱和(例如因为Linux上的OOM killer)。请注意,Windows在剩余内存非常有限(即ZRAM)时压缩RAM中的数据。这种方法对于具有许多零的数组效果很好。但是,如果稍后分配其他数组并从RAM中读回密集数组,则操作系统需要从RAM中解压缩数据,并且需要更多的空间。如果在解压缩过程中剩余的RAM内容不能被压缩得那么好,则很可能会发生内存不足。

当然,我将numpy稀疏矩阵转换为scipy csr_matrix

CSR matrices编码为3个1维数组:

  • 一个包含源矩阵非零值的data数组;
  • 一个引用当前行非零项位置的column索引数组;
  • 一个参考两个前置数组中第一个项偏移量的row索引。

在实际使用中,后2个数组是32位整数数组,在Linux上可能是64位整数。由于您的输入矩阵只有10%的非零值,这意味着data应该占用10000*600000*8*0.1 = 4.5 GiBcolumn在Linux上也应该占用相同的大小(因为它们都包含64位值),在Windows上则需要2.2 GiB,row在Linux上应该占用10000*8 = 78 KiB,在Windows上甚至更小。因此,在Linux上,CSR矩阵总大小约为9 GiB,在Windows上约为6.7 GiB。这仍然非常大。

使用copy=False不是一个好主意,因为CSR矩阵将引用巨大的初始数组(据我所知,data将引用x)。这意味着需要保留x在内存中,所以结果内存规格约为44.7 GiB + 2.2~4.5 GiB = 46.9~49.2 GiB。更不用说涉及稀疏矩阵的矩阵乘法通常会更慢(除非稀疏度很小)。最好的方法是复制数组内容,从而仅保留非零值,使用copy=True。

x.astype("float16", copy=False)

这不可能将数组转换为具有不同数据类型的另一个数组,而无需复制它,特别是如果每个项目的大小在内存中不同。 即使可以这样做,这也没有意义,因为目标是减少输入的大小,以创建一个新数组,而不是保留初始数组。

# 增加了一些原因的内存要求,并死亡

这有两个原因。

首先,Scipy目前还不支持稀疏矩阵的float16数据类型。您可以通过检查sparse_x.dtype来验证这一点:当xfloat16时,它是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时,selfother必须具有相同的类型(在类似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) # Efficient conversion

y = np.random.randint(0, 2, size=(600_000, 256), dtype=np.int8)
np.subtract(np.multiply(y, 2, out=y), 1, out=y) # In-place modification

一个简单的纯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

# Only for CSR sparse matrices
@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编译器的优化不足)。


1
非常感谢您提供如此详细和精美的答案。这看起来比我原先想到的要好得多。让我试一试。关于 .toarray(),它只是用来生成数据的。我一开始就有一个稀疏的numpy数组,后来将其转换为scipy稀疏矩阵。 - Grayrigel
确实,从内存角度来看这似乎更好。一个快速的问题:我如何修改sparse_compute以适应sps.lil_matrix?我使用lil_matrix时会得到一个SparseEfficiencyWarning警告。根据文档,它似乎是更改稀疏性的更好选择。 - Grayrigel
2
请勿使用lil矩阵,因为Python对象的内存开销极大。即使您可以分配空间,那个9GB的CSR矩阵在LIL矩阵中将变成90GB左右,实际上是无法使用的。 - CJR
@CJR 谢谢您的建议。我尝试使用LIL稀疏矩阵时发现性能下降了,因此我继续使用CSR矩阵并忽略了“SparseEfficiencyWarning”警告。它运行得非常快。 - Grayrigel

1

对于稀疏的 csr 矩阵 M 和密集数组 x,最深的 Python 代码为 M@x

In [28]: M._mul_multivector??
Signature: M._mul_multivector(other)
Docstring: <no docstring>
Source:   
    def _mul_multivector(self, other):
        M, N = self.shape
        n_vecs = other.shape[1]  # number of column vectors

        result = np.zeros((M, n_vecs),
                          dtype=upcast_char(self.dtype.char, other.dtype.char))

        # csr_matvecs or csc_matvecs
        fn = getattr(_sparsetools, self.format + '_matvecs')
        fn(M, N, n_vecs, self.indptr, self.indices, self.data,
           other.ravel(), result.ravel())

        return result

fn 在这里是编译代码。它获取 csr 矩阵的属性数组、密集数组作为一维数组,以及预分配的 result 数组 - 同样作为一个扁平的 view

我想知道创建 result 时为什么会出现内存错误。因为我怀疑 fn 使用该内存作为其工作空间,并不尝试使用更多的内存。

我知道稀疏矩阵乘法是通过两个编译步骤完成的。第一个步骤确定结果的维度和 nnz,第二个步骤实际执行计算。在两个步骤之间的 Python 代码创建 result 数组,就像在这里所做的那样。而在此处进行 @ 运算时可能会在 result 分配中发生内存错误。


0
如果您已经创建了没有任何内存问题的数组(就像我为准备好的示例大小所遇到的问题),为什么不使用迭代来满足需求呢?点积等效循环可能是一个解决方案,可以通过numba加速:
@nb.njit  # ("float64[:, ::1](float64[:, ::1], int32[:, ::1])")
def dot(a, b):

    dot_ = np.zeros((a.shape[0], b.shape[1]))
    for i in range(a.shape[1]):
        for j in range(a.shape[0]):
            for k in range(b.shape[1]):
                dot_[j, k] += a[j, i] * b[i, k]
    return dot_

结果将很容易适应内存,正如Warren在评论中提到的。

通过在循环中创建形状为(x.shape[1])的 1D (-1, 1) 随机数组,并通过每个 x 行进行点乘来填充一个空数组以获得所需的形状(x.shape[0],y_shape),可能有助于满足内存限制(最好使用编译后的 numba 循环而不是 np.dot),如果没有其他方法的话,这种方法在时间上效率非常低下(可以通过分批处理来避免许多循环),即:

def batch(a, y_shape):
    res = np.zeros((a.shape[0], y_shape))
    for row_ in range(a.shape[0]):
        for col_ in range(y_shape):
            res[row_, col_] = np.dot(a[row_, :], np.random.choice([-1, 1], size=a.shape[1]))
    return res

1
为什么不直接使用迭代?使用BLAS进行矩阵运算而不是重新发明轮子有很好的理由。这没有真正优于dgemm(在numpy背后封装)和一堆劣势(它没有并行化,不能有效地使用寄存器等)。 - CJR
@CJR,没错,这些方法之间存在差异,这个解决方案需要根据情况的需求进行更多的评估(在这个问题中,我没有看到使用此解决方案的任何限制)。我已经更新了答案,并提供了一个小提示来涉及它。代码可以并行化或以其他风格编写,但这不是问题的主要关注点。在测试中,这个解决方案消耗的内存较少,可以满足作者的需求。 - Ali_Sh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接