【问题标题】:Filtering a NumPy Array过滤 NumPy 数组
【发布时间】:2020-02-13 18:55:27
【问题描述】:

假设我有一个 NumPy 数组 arr,我想按元素过滤,例如 我只想得到低于某个阈值k的值。

有几种方法,例如:

  1. 使用生成器:np.fromiter((x for x in arr if x < k), dtype=arr.dtype)
  2. 使用布尔蒙版切片:arr[arr < k]
  3. 使用np.where():arr[np.where(arr < k)]
  4. 使用np.nonzero():arr[np.nonzero(arr < k)]
  5. 使用基于 Cython 的自定义实现
  6. 使用基于 Numba 的自定义实现

哪个最快?内存效率如何?


(已编辑:根据@ShadowRanger 评论添加np.nonzero()

【问题讨论】:

  • 选项 #1 与选项 2 和 3 完全不同。它返回一个新的布尔数组(转换为原始的 dtype),而不是一个新的过滤数组。
  • @ShadowRanger 感谢您发现该问题,现已修复。
  • K.另一个注意事项:numpy 自己的文档不鼓励仅在一个条件下使用numpy.wherenumpy.where(condition)(只有一个条件,没有x/y args)等价于numpy.asarray(condition).nonzero();推荐的方法是直接致电.nonzero(),例如arr[(arr < k).nonzero()]。正确处理子类,并且以这种方式启动时运行速度更快。
  • @ShadowRanger 你知道arr[(arr > k).nonzero()]arr[arr > k] 之间的区别是什么吗?它们在一些简单的测试中表现相同。
  • @AlexanderCécile 我已将其包含在测试中,简短的回答是“没那么多”。

标签: python numpy cython numba


【解决方案1】:

总结

以下测试旨在提供对不同方法的一些见解,应谨慎使用。

这里测试的并不是完全通用的过滤,而只是应用一个阈值,它具有计算条件非常快的显着特征。如果条件意味着昂贵的计算,将会得到非常不同的结果。

基本上 2 次加速(使用 Numba 或 Cython ——只要您事先知道类型)将是最快且内存效率更高的,除了非常大的输入,单次 Numba/ Cython 更快(以更大的临时内存使用为代价)。使用np.where()/np.nonzero(),而不是直接使用掩码,可能会导致计算速度稍快,并且只要输出的大小小于 50%,通常不会造成伤害(可能除了较大的临时内存占用之外)的输入。 np.fromiter() 方法慢得多,但不会产生大型临时对象。


定义

  1. 使用生成器:
def filter_fromiter(arr, k):
    return np.fromiter((x for x in arr if x < k), dtype=arr.dtype)
  1. 使用布尔蒙版切片:
def filter_mask(arr, k):
    return arr[arr < k]
  1. 使用np.where():
def filter_where(arr, k):
    return arr[np.where(arr < k)]
  1. 使用np.nonzero()
def filter_nonzero(arr, k):
    return arr[np.nonzero(arr < k)]
  1. 使用基于 Cython 的自定义实现:
    • 单通filter_cy()
    • 两次通过filter2_cy()
%%cython -c-O3 -c-march=native -a
#cython: language_level=3, boundscheck=False, wraparound=False, initializedcheck=False, cdivision=True, infer_types=True


cimport numpy as cnp
cimport cython as ccy

import numpy as np
import cython as cy


cdef long NUM = 1048576
cdef long MAX_VAL = 1048576
cdef long K = 1048576 // 2


cdef int smaller_than_cy(long x, long k=K):
    return x < k


cdef size_t _filter_cy(long[:] arr, long[:] result, size_t size, long k):
    cdef size_t j = 0
    for i in range(size):
        if smaller_than_cy(arr[i]):
            result[j] = arr[i]
            j += 1
    return j


cpdef filter_cy(arr, k):
    result = np.empty_like(arr)
    new_size = _filter_cy(arr, result, arr.size, k)
    result.resize(new_size)
    return result


cdef size_t _filtered_size(long[:] arr, size_t size, long k):
    cdef size_t j = 0
    for i in range(size):
        if smaller_than_cy(arr[i]):
            j += 1
    return j


cpdef filter2_cy(arr, k):
    cdef size_t new_size = _filtered_size(arr, arr.size, k)
    result = np.empty(new_size, dtype=arr.dtype)
    new_size = _filter_cy(arr, result, arr.size, k)
    return result

import functools


filter_np_cy = functools.partial(filter_cy, k=K)
filter_np_cy.__name__ = 'filter_np_cy'


filter2_np_cy = functools.partial(filter2_cy, k=K)
filter2_np_cy.__name__ = 'filter2_np_cy'
  1. 使用基于 Numba 的自定义实现
    • 单通filter_np_nb()
    • 两次通过filter2_np_nb()
import numba as nb
import functools


@nb.njit
def filter_func(x, k):
    return x < k


@nb.njit
def filter_nb(arr, result, k):
    j = 0
    for i in range(arr.size):
        if filter_func(arr[i], k):
            result[j] = arr[i]
            j += 1
    return j


def filter_np_nb(arr, k=K):
    result = np.empty_like(arr)
    j = filter_nb(arr, result, k)
    result.resize(j, refcheck=False)
    return result


@nb.njit
def filter2_nb(arr, k):
    j = 0
    for i in range(arr.size):
        if filter_func(arr[i], k):
            j += 1
    result = np.empty(j, dtype=arr.dtype)
    j = 0
    for i in range(arr.size):
        if filter_func(arr[i], k):
            result[j] = arr[i]
            j += 1
    return result


filter2_np_nb = functools.partial(filter2_nb, k=K)
filter2_np_nb.__name__ = 'filter2_np_nb'

时序基准

基于生成器的 filter_fromiter() 方法比其他方法慢得多(大约慢 2 个数量级,因此在图表中省略了)。

时间取决于输入数组的大小和过滤项的百分比。

作为输入大小的函数

第一个图表将时序作为输入大小的函数(对于大约 50% 的过滤掉的元素):

一般来说,基于 Numba 的方法始终是最快的,紧随其后的是 Cython 方法。在它们中,两遍方法通常是最快的,除了非常大的输入,单遍方法往往会接管。在 NumPy 中,基于np.where() 和基于np.nonzero() 的方法基本相同(除了非常小的输入,np.nonzero() 似乎稍慢一些),它们都比布尔掩码切片更快,除了对于非常小的输入(低于约 100 个元素),布尔掩码切片速度更快。 此外,对于非常小的输入,基于 Cython 的解决方案比基于 NumPy 的解决方案要慢。

作为填充函数

第二张图将时间作为通过过滤器的项目的函数(对于大约 100 万个元素的固定输入大小):

第一个观察结果是,所有方法在接近 50% 填充时最慢,而在填充较少或较多时,它们更快,并且在没有填充时最快(过滤出值的百分比最高,通过值的百分比最低如图的 x 轴所示)。 同样,Numba 和 Cython 版本通常都比基于 NumPy 的版本更快,Numba 几乎总是最快,Cython 在图表的最右侧部分胜过 Numba。 对于较大的填充值,两遍方法具有增加的边际速度增益,直到大约。 50%,之后单程接管速度领奖台。 在 NumPy 中,基于np.where() 和基于np.nonzero() 的方法再次基本相同。 在比较基于 NumPy 的解决方案时,np.where()/np.nonzero() 解决方案在填充低于 ~60% 时优于布尔掩码切片,之后布尔掩码切片变得最快。

(完整代码可用here


内存注意事项

基于生成器的filter_fromiter() 方法只需要最少的临时存储空间,与输入的大小无关。 在内存方面,这是最有效的方法。 Cython / Numba 两遍方法具有相似的内存效率,因为输出的大小是在第一遍中确定的。

在内存方面,Cython 和 Numba 的单通道解决方案都需要输入大小的临时数组。 因此,与两遍或基于生成器的一遍相比,这些并不是非常节省内存。然而,与掩码相比,它们具有相似的渐近临时内存占用,但常数项通常大于掩码。

布尔掩码切片解决方案需要一个与输入大小相同但类型为 bool 的临时数组,在 NumPy 中为 1 位,因此这比典型的 NumPy 数组的默认大小小约 64 倍64位系统。

基于np.nonzero()/np.where() 的解决方案与第一步(在np.nonzero()/np.where() 内)中的布尔掩码切片具有相同的要求,它被转换为一系列ints(通常int64 在 64 位系统上)在第二步(np.nonzero()/np.where() 的输出)。因此,第二步对内存的要求是可变的,具体取决于过滤元素的数量。


备注

  • 在指定不同的过滤条件时,生成器方法也是最灵活的
  • Cython 解决方案需要指定数据类型以使其速度更快,或者需要额外的工作来进行多种类型的分派
  • 对于 Numba 和 Cython,过滤条件可以指定为通用函数(因此不需要硬编码),但必须在各自的环境中指定,并且必须注意确保这是为了速度而正确编译的,否则会观察到明显的减速。
  • 单通道解决方案需要额外的代码来处理未使用的(但最初分配的)内存。
  • NumPy 方法确实返回输入的视图,而是一个副本,作为advanced indexing 的结果:
arr = np.arange(100)
k = 50
print('`arr[arr > k]` is a copy: ', arr[arr > k].base is None)
# `arr[arr > k]` is a copy:  True
print('`arr[np.where(arr > k)]` is a copy: ', arr[np.where(arr > k)].base is None)
# `arr[np.where(arr > k)]` is a copy:  True
print('`arr[:k]` is a copy: ', arr[:k].base is None)
# `arr[:k]` is a copy:  False

(已编辑:包括基于 np.nonzero() 的解决方案和修复内存泄漏并避免在单遍 Cython/Numba 版本中复制,包括两遍 Cython/Numba 版本——基于 @ShadowRanger、@PaulPanzer、@max9111和@DavidW cmets。)

【讨论】:

  • 不错的努力 --- 我一直怀疑通过 where 传递面具通常更快(尽管不应该如此),所以感谢您确认。不过,我不认为 Cython 和 numba 的实现是公平的。两者基本上都泄漏了过度分配的内存,这是不允许正确的库函数做的事情。
  • 中等大小的 cython 和 numba 之间的差异可能是由于 gcc 与 clang,gcc 似乎无法充分利用此类过滤功能(另见 stackoverflow.com/q/52046301/5769463)。对于较大的大小,即其他缓存级别,任务变得越来越受内存限制,因此 cython 和 numba 之间的差异较小。
  • 几乎在任何情况下运行两次 for 循环都会更快。 (第一次运行得到最终的数组大小,第二次运行将满足条件的数据复制到第一次运行时分配的最终大小的输出数组中)
  • 随着代码变长,编写一个基于给定条件 (>,stackoverflow.com/a/58418715/4045774 小函数被内联了,所以性能应该不会有任何下降。
  • ndarray.resize 不会在不需要复制的情况下释放单通道版本的额外内存吗?
猜你喜欢
  • 2019-01-31
  • 2018-06-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-09-29
  • 2021-07-05
相关资源
最近更新 更多