C数组与NumPy数组的区别

18

在性能方面(代数运算、查找、缓存等方面),C数组(可以公开为C数组,或cython.view.array [Cython数组],或前两者的memoryview)和NumPy数组(在Cython中不应该有Python开销)之间是否有差异

编辑:

我应该提到,在Cython中,NumPy数组使用静态类型,并且dtype是NumPy编译时数据类型(例如cdef np.int_tcdef np.float32_t),而C情况下的类型是C等效类型(cdef int_tcdef float

编辑2:

这里是Cython Memoryview文档中的示例,以进一步说明我的问题:

from cython.view cimport array as cvarray
import numpy as np

# Memoryview on a NumPy array
narr = np.arange(27, dtype=np.dtype("i")).reshape((3, 3, 3))
cdef int [:, :, :] narr_view = narr

# Memoryview on a C array
cdef int carr[3][3][3]
cdef int [:, :, :] carr_view = carr

# Memoryview on a Cython array
cyarr = cvarray(shape=(3, 3, 3), itemsize=sizeof(int), format="i")
cdef int [:, :, :] cyarr_view = cyarr

坚持使用 C 数组Cython 数组NumPy 数组 之间有什么区别吗?


4
你可以去进行基准测试,然后回来告诉我们结果如何? - TC1
2个回答

27
我的知识仍然不够完美,但这可能会有所帮助。我进行了一些非正式的基准测试,展示了每种数组类型适用于哪些情况,并对我发现的结果感到好奇。
虽然这些数组类型在许多方面都不同,但如果您正在使用大型数组进行重计算,则应该能够从中获得类似的性能,因为逐个项目的访问应该在各方面大致相同。
NumPy数组是使用Python的C API实现的Python对象。NumPy数组确实提供了一个C级别的API,但它们不能独立于Python解释器创建。它们特别有用,因为NumPy和SciPy中提供了许多不同的数组操作例程。
Cython内存视图也是Python对象,但它是作为Cython扩展类型制作的。它似乎并不是为纯Python设计的,因为它不是Cython的一部分,无法直接从Python导入,但您可以从Cython函数返回视图。您可以查看https://github.com/cython/cython/blob/master/Cython/Utility/MemoryView.pyx上的实现。
C数组是C语言中的本机类型。它像指针一样索引,但数组和指针是不同的。这里有一些很好的讨论:http://c-faq.com/aryptr/index.html。它们可以在堆栈上分配,并且更容易被C编译器优化,但是在Cython外部访问它们将更加困难。我知道您可以从其他程序动态分配的内存中创建NumPy数组,但以这种方式似乎更加困难。Travis Oliphant在http://blog.enthought.com/python/numpy-arrays-with-pre-allocated-memory/上发布了一个示例。如果您正在使用C数组或指针作为程序内的临时存储,则应该非常适合您。对于任何其他类型的切片或矢量化计算,它们将不太方便,因为您必须使用显式循环自己完成所有操作,但它们应该更快地分配和释放,并且应该提供良好的速度基线。
Cython还提供了一个数组类。它看起来是为内部使用而设计的。当复制内存视图时,将创建实例。请参见http://docs.cython.org/src/userguide/memoryviews.html#view-cython-arrays
在Cython中,您还可以分配内存并索引指针以将分配的内存视为数组。请参见http://docs.cython.org/src/tutorial/memory_allocation.html
这里有一些基准测试结果,显示了对于索引大数组的相似性能。这是Cython文件。
from numpy cimport ndarray as ar, uint64_t
cimport cython
import numpy as np

@cython.boundscheck(False)
@cython.wraparound(False)
def ndarr_time(uint64_t n=1000000, uint64_t size=10000):
    cdef:
        ar[uint64_t] A = np.empty(n, dtype=np.uint64)
        uint64_t i, j
    for i in range(n):
        for j in range(size):
            A[j] = n

def carr_time(uint64_t n=1000000):
    cdef:
        ar[uint64_t] A = np.empty(n, dtype=np.uint64)
        uint64_t AC[10000]
        uint64_t a
        int i, j
    for i in range(n):
        for j in range(10000):
            AC[j] = n

@cython.boundscheck(False)
@cython.wraparound(False)
def ptr_time(uint64_t n=1000000, uint64_t size=10000):
    cdef:
        ar[uint64_t] A = np.empty(n, dtype=np.uint64)
        uint64_t* AP = &A[0]
        uint64_t a
        int i, j
    for i in range(n):
        for j in range(size):
            AP[j] = n

@cython.boundscheck(False)
@cython.wraparound(False)
def view_time(uint64_t n=1000000, uint64_t size=10000):
    cdef:
        ar[uint64_t] A = np.empty(n, dtype=np.uint64)
        uint64_t[:] AV = A
        uint64_t i, j
    for i in range(n):
        for j in range(size):
            AV[j] = n

使用IPython计时,我们得到了以下结果。
%timeit -n 10 ndarr_time()
%timeit -n 10 carr_time()
%timeit -n 10 ptr_time()
%timeit -n 10 view_time()

10 loops, best of 3: 6.33 s per loop
10 loops, best of 3: 3.12 s per loop
10 loops, best of 3: 6.26 s per loop
10 loops, best of 3: 3.74 s per loop

考虑到 Efficiency: arrays vs pointers 中指出数组不太可能比指针快,这些结果让我感到有点奇怪。看起来某种编译器优化使得纯C数组和类型化内存视图更快。我尝试关闭C编译器上的所有优化标志,并获得了时间记录。

1 loops, best of 3: 25.1 s per loop
1 loops, best of 3: 25.5 s per loop
1 loops, best of 3: 32 s per loop
1 loops, best of 3: 28.4 s per loop

在我看来,无论是哪种方式逐个访问元素,都差不多,只不过C数组和Cython内存视图更容易被编译器优化。

更多评论可以在我之前发现的这两篇博客文章中看到: http://jakevdp.github.io/blog/2012/08/08/memoryview-benchmarks/ http://jakevdp.github.io/blog/2012/08/16/memoryview-benchmarks-2/

在第二篇博客文章中,作者提到如果内存视图切片被内联,则它们可以提供类似指针算术的速度。 在我的一些测试中,我注意到明确内联使用内存视图切片的函数并不总是必要的。 作为一个例子,我将计算数组中任意两行的内积。

from numpy cimport ndarray as ar
cimport cython
from numpy import empty

# An inlined dot product
@cython.boundscheck(False)
@cython.wraparound(False)
cdef inline double dot_product(double[:] a, double[:] b, int size):
    cdef int i
    cdef double tot = 0.
    for i in range(size):
        tot += a[i] * b[i]
    return tot

# non-inlined dot-product
@cython.boundscheck(False)
@cython.wraparound(False)
cdef double dot_product_no_inline(double[:] a, double[:] b, int size):
    cdef int i
    cdef double tot = 0.
    for i in range(size):
        tot += a[i] * b[i]
    return tot

# function calling inlined dot product
@cython.boundscheck(False)
@cython.wraparound(False)
def dot_rows_slicing(ar[double,ndim=2] A):
    cdef:
        double[:,:] Aview = A
        ar[double,ndim=2] res = empty((A.shape[0], A.shape[0]))
        int i, j
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            res[i,j] = dot_product(Aview[i], Aview[j], A.shape[1])
    return res

# function calling non-inlined version
@cython.boundscheck(False)
@cython.wraparound(False)
def dot_rows_slicing_no_inline(ar[double,ndim=2] A):
    cdef:
        double[:,:] Aview = A
        ar[double,ndim=2] res = empty((A.shape[0], A.shape[0]))
        int i, j
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            res[i,j] = dot_product_no_inline(Aview[i], Aview[j], A.shape[1])
    return res

# inlined dot product using numpy arrays
@cython.boundscheck(False)
@cython.boundscheck(False)
cdef inline double ndarr_dot_product(ar[double] a, ar[double] b):
    cdef int i
    cdef double tot = 0.
    for i in range(a.size):
        tot += a[i] * b[i]
    return tot

# non-inlined dot product using numpy arrays
@cython.boundscheck(False)
@cython.boundscheck(False)
cdef double ndarr_dot_product_no_inline(ar[double] a, ar[double] b):
    cdef int i
    cdef double tot = 0.
    for i in range(a.size):
        tot += a[i] * b[i]
    return tot

# function calling inlined numpy array dot product
@cython.boundscheck(False)
@cython.wraparound(False)
def ndarr_dot_rows_slicing(ar[double,ndim=2] A):
    cdef:
        ar[double,ndim=2] res = empty((A.shape[0], A.shape[0]))
        int i, j
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            res[i,j] = ndarr_dot_product(A[i], A[j])
    return res

# function calling nun-inlined version for numpy arrays
@cython.boundscheck(False)
@cython.wraparound(False)
def ndarr_dot_rows_slicing_no_inline(ar[double,ndim=2] A):
    cdef:
        ar[double,ndim=2] res = empty((A.shape[0], A.shape[0]))
        int i, j
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            res[i,j] = ndarr_dot_product(A[i], A[j])
    return res

# Version with explicit looping and item-by-item access.
@cython.boundscheck(False)
@cython.wraparound(False)
def dot_rows_loops(ar[double,ndim=2] A):
    cdef:
        ar[double,ndim=2] res = empty((A.shape[0], A.shape[0]))
        int i, j, k
        double tot
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            tot = 0.
            for k in range(A.shape[1]):
                tot += A[i,k] * A[j,k]
            res[i,j] = tot
    return res

计时这些操作,我们可以看到:
A = rand(1000, 1000)
%timeit dot_rows_slicing(A)
%timeit dot_rows_slicing_no_inline(A)
%timeit ndarr_dot_rows_slicing(A)
%timeit ndarr_dot_rows_slicing_no_inline(A)
%timeit dot_rows_loops(A)

1 loops, best of 3: 1.02 s per loop
1 loops, best of 3: 1.02 s per loop
1 loops, best of 3: 3.65 s per loop
1 loops, best of 3: 3.66 s per loop
1 loops, best of 3: 1.04 s per loop

结果在显式内联和没有内联的情况下都很快。 在两种情况下,经过类型化的内存视图与没有切片编写的函数版本相当。
在博客文章中,他不得不写一个特定的例子来强制编译器不内联一个函数。 看起来像一个体面的C编译器(我正在使用MinGW)能够处理这些优化而不需要告诉它内联某些函数。 即使没有显示内联,Memoryviews 在 Cython 模块内在函数之间传递数组切片时也可能更快。
然而,在这个特定的情况下,甚至将循环推到 C 中也无法达到通过正确使用矩阵乘法所能实现的速度。 BLAS 仍然是执行此类操作的最佳方法。
%timeit A.dot(A.T)
10 loops, best of 3: 25.7 ms per loop

还有一种自动将NumPy数组转换为memoryviews的方法,如下所示:

cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
def cysum(double[:] A):
    cdef tot = 0.
    cdef int i
    for i in range(A.size):
        tot += A[i]
    return tot

唯一的问题是,如果你想要一个函数返回一个NumPy数组,你需要使用np.asarray将内存视图对象转换回NumPy数组。 这是一个相对廉价的操作,因为内存视图符合http://www.python.org/dev/peps/pep-3118/
结论
对于Cython模块内部使用而言,类型化的内存视图似乎是NumPy数组的一个可行替代品。 使用内存视图进行数组切片会更快,但是与NumPy数组相比,内存视图没有那么多的函数和方法编写。 如果您不需要调用许多NumPy数组方法,并且想要轻松地进行数组切片,则可以使用内存视图来代替NumPy数组。 如果您需要给定数组的数组切片和NumPy功能,则可以创建指向与NumPy数组相同的内存的内存视图。 然后,您可以使用视图在函数之间传递切片,并使用数组调用NumPy函数。 这种方法仍然有些局限性,但如果您大部分处理都是使用单个数组完成的话,它将工作得很好。
C数组和/或动态分配的内存块可能对中间计算很有用,但是它们不容易传回Python以供使用。 我认为,动态分配多维C数组也更加麻烦。 我所知道的最好方法是分配一个大块的内存,然后使用整数算术将其索引为多维数组。 如果您想要轻松地动态分配数组,则可能会出现问题。 另一方面,对于C数组,分配时间可能快得多。 其他数组类型旨在几乎与C数组一样快且更加方便,因此除非有强制性原因,否则建议使用它们。
更新:如@Veedrac的答案中所提到的,您仍然可以将Cython内存视图传递给大多数NumPy函数。 当您这样做时,NumPy通常必须创建一个新的NumPy数组对象来使用内存视图,因此这会稍微慢一些。 对于大型数组,效果将是可以忽略不计的。 无论数组大小如何,调用np.asarray以获取内存视图将相对较快。 但是,为了证明这种效果,这里还有另一个基准测试:
def npy_call_on_view(npy_func, double[:] A, int n):
    cdef int i
    for i in range(n):
        npy_func(A)

def npy_call_on_arr(npy_func, ar[double] A, int n):
    cdef int i
    for i in range(n):
        npy_func(A)

在IPython中:
from numpy.random import rand
A = rand(1)
%timeit npy_call_on_view(np.amin, A, 10000)
%timeit npy_call_on_arr(np.amin, A, 10000)

输出:

10 loops, best of 3: 282 ms per loop
10 loops, best of 3: 35.9 ms per loop

我尝试选择一个例子来很好地展示这种效果。除非涉及到许多相对较小的数组上的NumPy函数调用,否则这不会改变时间。请记住,无论我们以哪种方式调用NumPy,Python函数调用仍然会发生。
这仅适用于NumPy中的函数。大多数数组方法对于memoryviews不可用(一些属性仍然可用,例如sizeshapeT)。例如,使用NumPy数组的A.dot(A.T)将变为np.dot(A, A.T)

“NumPy数组是具有C API的Python对象。” 你是指反过来吗? - asmeurer
1
@asmeurer 不,我不是这样说的,因为我不想暗示 NumPy 数组可以在没有 Python 的情况下使用。虽然它们是使用 Python 的 C API 实现的,但它们仍然是 Python 对象,不能独立于 Python 解释器使用。我在你指出的那句话后面澄清了这一点。谢谢你提出这个问题,我会尝试想出更好的措辞来表达第一句话。 - IanH
ptr_time()版本可能比carr_time()慢,因为您使用了range(size)而不是range(10000),在x86上,编译器在使用内存之前有4个寄存器,即eax=i,ebx=AP,ecx=j,edx=n,没有留下size的寄存器,因此会发生额外的内存操作,size是dword[ebp-8],ebp是堆栈指针。在carr版本中,除了循环与常量进行比较之外,代码基本相同。如果更改此内容,则理论上应该匹配。但是,根据cython生成的内容和c编译器的不同,仍可能存在差异。 - Glen Fletcher

4
不要使用cython.view.array,而要使用cpython.array.array。有关详细信息,请参见我的这个答案,尽管它只涉及速度。建议将cython.view.array视为“演示”材料,而将cpython.array.array视为实际的坚实实现。这些数组非常轻巧,并且在将它们仅用作临时空间时更好。此外,如果您曾经被malloc所诱惑,那么对这些数组进行原始访问并不会变慢,实例化只需要两倍的时间。
关于IanH的:

如果您需要给定数组的数组切片和NumPy功能,则可以创建指向与NumPy数组相同的内存的内存视图。

值得注意的是,memoryviews具有“base”属性,许多Numpy函数也可以接受memoryviews,因此这些不必是分开的变量。

1
好的观点。感谢指出这些问题。我已经更新了我的答案,并加上了一些关于使用NumPy函数的评论。 - IanH

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