【问题标题】:Fastest way to sum columns with lag对有滞后的列求和的最快方法
【发布时间】:2021-07-23 22:47:46
【问题描述】:

给定一个方阵,我想将每一行按其行号移动并对列求和。例如:

array([[0, 1, 2],        array([[0, 1, 2],
       [3, 4, 5],    ->            [3, 4, 5],      ->   array([0, 1+3, 2+4+6, 5+7, 8]) = array([0, 4, 12, 12, 8])
       [6, 7, 8]])                    [6, 7, 8]])

我有 4 个解决方案 - fastslowslowerslowest,它们的作用完全相同,并且按速度排名:

def fast(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    for i in range(n):
        retval[i:(i+n)] += A[i, :]
    return retval
def slow(A):
    n = A.shape[0]
    indices = np.arange(n)
    indices = indices + indices[:,None]
    return np.bincount(indices.ravel(), A.ravel())
def slower(A):
    r, _ = A.shape
    retval = np.c_[A, np.zeros((r, r), dtype=A.dtype)].ravel()[:-r].reshape(r, -1)
    return retval.sum(0)
def slowest(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    indices = np.arange(n)
    indices = indices + indices[:,None]
    np.add.at(retval, indices, A)
    return retval

令人惊讶的是,非矢量化解决方案是最快的。这是我的基准:

A = np.random.randn(1000,1000)

%timeit fast(A)
# 1.85 ms ± 20 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit slow(A)
# 3.28 ms ± 9.55 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit slower(A)
# 4.07 ms ± 18.7 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit slowest(A)
# 58.4 ms ± 993 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

是否存在更快的解决方案?如果没有,有人可以解释为什么事实上fast 是最快的吗?

编辑

slow 略有加速:

def slow(A):
    n = A.shape[0]
    indices = np.arange(2*n-1)
    indices = np.lib.stride_tricks.as_strided(indices, A.shape, (8,8))
    return np.bincount(indices.ravel(), A.ravel())

以与 Pierre 相同的方式绘制运行时(以 2**15 作为上限 - 由于某种原因 slow 无法处理此大小)

对于100x100 的数组,slow 比任何解决方案(不使用numba)都要快一点。 sum_antidiagonals 仍然是 1000x1000 数组的最佳选择。

【问题讨论】:

  • 注意到通过使用np.lib.stride_tricks.as_strided 生成笛卡尔索引,我可以将slow 缩短约0.87 毫秒。
  • 关于 why fast() 是最快的:使用切片作为左值非常聪明,并且使用 numpy 非常快。除了 Python for 循环之外,整个函数几乎完成了所需的最少操作量(r*c 添加,或您的术语中的 n^2); for 循环的成本仅为O(n)(时间常数很小),与添加的O(n^2) 相比变得可以忽略不计。因此fast() 渐近地达到just_sum_0() 的速度,这必须是O(n^2) 加法运算的最快速度。

标签: python arrays numpy optimization vectorization


【解决方案1】:

这是一种有时比您的“fast()”版本更快的方法,但对于n x n 数组,它仅限于n(大约在30 到1000 之间)的有限范围内.循环 (fast()) 很难在大型阵列上击败,即使使用 numba,实际上也渐近收敛到简单的 a.sum(axis=0) 的时间,这表明它的效率差不多因为它适用于大型数组(感谢您的循环!)

我将调用sum_antidiagonals() 的方法在a 的条纹版本上使用np.add.reduce(),并在由相对较小的一维数组构成的蒙版上使用条纹创建二维错觉数组(不消耗更多内存)。

此外,它不限于方形数组(但fast() 也可以轻松适应这种泛化,请参阅本文底部的fast_g())。

def sum_antidiagonals(a):
    assert a.flags.c_contiguous
    r, c = a.shape
    s0, s1 = a.strides
    z = np.lib.stride_tricks.as_strided(
        a, shape=(r, c+r-1), strides=(s0 - s1, s1), writeable=False)
    # mask
    kern = np.r_[np.repeat(False, r-1), np.repeat(True, c), np.repeat(False, r-1)]
    mask = np.fliplr(np.lib.stride_tricks.as_strided(
        kern, shape=(r, c+r-1), strides=(1, 1), writeable=False))
    return np.add.reduce(z, where=mask)

注意它不限于方阵:

>>> sum_antidiagonals(np.arange(15).reshape(5,3))
array([ 0,  4, 12, 21, 30, 24, 14])

说明

要了解它的工作原理,让我们通过一个示例来检查这些条带数组。

给定一个初始数组a,即(3, 2)

a = np.arange(6).reshape(3, 2)
>>> a
array([[0, 1],
       [2, 3],
       [4, 5]])

# after calculating z in the function
>>> z
array([[0, 1, 2, 3],
       [1, 2, 3, 4],
       [2, 3, 4, 5]])

你可以看到它几乎是我们想要的sum(axis=0),除了上下三角形是不需要的。我们真正想要总结的是:

array([[0, 1, -, -],
       [-, 2, 3, -],
       [-, -, 4, 5]])

输入掩码,我们可以从一维内核开始构建:

kern = np.r_[np.repeat(False, r-1), np.repeat(True, c), np.repeat(False, r-1)]
>>> kern
array([False, False,  True,  True, False, False])

我们使用了一个有趣的切片:(1, 1),这意味着我们重复同一行,但每次滑动一个元素:

>>> np.lib.stride_tricks.as_strided(
...        kern, shape=(r, c+r-1), strides=(1, 1), writeable=False)
array([[False, False,  True,  True],
       [False,  True,  True, False],
       [ True,  True, False, False]])

然后我们只需将其向左/向右翻转,并将其用作np.add.reduce()where 参数。

速度

b = np.random.normal(size=(1000, 1000))

# check equivalence with the OP's fast() function:
>>> np.allclose(fast(b), sum_antidiagonals(b))
True

%timeit sum_antidiagonals(b)
# 1.83 ms ± 840 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit fast(b)
# 2.07 ms ± 15.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

在这种情况下,它会快一点,但只有大约 10%。

在 300x300 阵列上,sum_antidiagonals()fast() 快 2.27 倍。

但是!

尽管将zmask 放在一起非常快(在上面的1000x1000 示例中np.add.reduce() 之前的整个设置只需要46 µs),总和本身就是O[r (r+c)],即使只有O[r c]需要实际添加(mask == True)。因此,正方形数组的运算量增加了大约 2 倍。

在一个 10K x 10K 的阵列上,这赶上了我们:

  • fast 需要 95 毫秒,而
  • sum_antidiagonals 需要 208 毫秒。

尺寸范围比较

我们将使用可爱的perfplot 包在n 范围内比较多种方法的速度:

perfplot.show(
    setup=lambda n: np.random.normal(size=(n, n)),
    kernels=[just_sum_0, fast, fast_g, nb_fast_i, nb_fast_ij, sum_antidiagonals],
    n_range=[2 ** k for k in range(3, 16)],
    equality_check=None,  # because of just_sum_0
    xlabel='n',
    relative_to=1,
)

观察

  • 如您所见,sum_antidiagonals() 相对于fast() 的速度优势仅限于n 的范围,大约在 30 到 1000 之间。
  • 它永远比不上numba 版本。
  • just_sum_0(),这只是 axis=0 的总和(因此是一个很好的底线基准,几乎不可能被击败),对于大型阵列来说只是稍微快一点。这一事实表明,fast() 的速度与处理大型数组的速度差不多。
  • 令人惊讶的是,numba 在一定大小后会减损(即在前几次运行以“烧入”LLVM 编译之后)。我不确定为什么会这样,但它似乎对大型阵列很重要。

其他功能的完整代码

(包括将fast 简单推广到非方形数组)

from numba import njit

@njit
def nb_fast_ij(a):
    # numba loves loops...
    r, c = a.shape
    z = np.zeros(c + r - 1, dtype=a.dtype)
    for i in range(r):
        for j in range(c):
            z[i+j] += a[i, j]
    return z

@njit
def nb_fast_i(a):
    r, c = a.shape
    z = np.zeros(c + r - 1, dtype=a.dtype)
    for i in range(r):
        z[i:i+c] += a[i, :]
    return z

def fast_g(a):
    # generalizes fast() to non-square arrays, also returns the same dtype
    r, c = a.shape
    z = np.zeros(c + r - 1, dtype=a.dtype)
    for i in range(r):
        z[i:i+c] += a[i]
    return z

def fast(A):
    # the OP's code
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    for i in range(n):
        retval[i:(i+n)] += A[i, :]
    return retval

def just_sum_0(a):
    # for benchmarking comparison
    return a.sum(axis=0)

【讨论】:

  • 你使用as_strided的方法真的很漂亮。感谢广泛的运行时分析,有趣的是numba 解决方案对于足够大的数组来说速度较慢。 fast 解决方案的功劳归于 stackoverflow.com/a/67088714/14923227
  • 我注意到bincount 方法对于小型数组(不包括任何numba 解决方案)具有最佳运行时间,因此我对我的帖子进行了编辑。
  • 是的,我还查看了bincount 解决方案,但没有包含它,因为它变得相当慢。我昨晚想出的另一个是通过np.trace():矢量化版本,然后是vtrace(np.fliplr(a), np.arange(n-1, -n, -1))
【解决方案2】:

最简单的加速方法(在我的电脑上大约 2/3 倍)是使用你的 fast 方法和 numba 包:

import numba
@numba.jit(nopython=True)
def fastNumba(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    for i in range(n):
        retval[i:(i+n)] += A[i, :]
    return retval

但使用numba 仅在此函数多次运行时才有意义。函数的第一次评估需要更多时间(因为编译)。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2018-03-10
    • 2021-02-19
    • 2012-07-02
    • 2021-06-01
    • 2023-03-25
    • 1970-01-01
    • 1970-01-01
    • 2019-03-24
    相关资源
    最近更新 更多