为什么这个简单附加函数的 Cython 实现比 Numba 慢?

Why is this Cython implementation of this simple appending function slower than Numba?

我有一个函数的 2 个版本,可以将一行附加到二维数组;一个在 Cython 中,另一个在 Numba 中。

Cython 版本的性能比 Numba 版本慢很多。我想优化 Cython 版本,使其性能至少与 Numba 版本一样好。

我正在用这个 timer.py 模块对代码进行计时: 导入时间

class Timer(object):
    def __init__(self, name='', output=print):
        self._name = name
        self._output = output

    def __enter__(self):
        self.start = time.time()
        return self

    def __exit__(self, a, b, c):
        self.end = time.time()
        self.time_taken = self.end - self.start
        self._output('%s Took %0.2fs seconds' % (self._name, self.time_taken))

我的 append_2d_cython.pyx 模块是:

#!python
#cython: boundscheck=False
#cython: wraparound=False


import numpy as np
cimport numpy as cnp

cnp.import_array()  # needed to initialize numpy-API


cpdef empty_2d(int d1, int d2):
    cdef:
        cnp.npy_intp[2] dimdim

    dimdim[0] = d1
    dimdim[1] = d2

    return cnp.PyArray_SimpleNew(2, dimdim, cnp.NPY_INT32)


cpdef append_2d(int[:, :] arr, int[:] value):

    cdef int[:, :] result

    result = empty_2d(arr.shape[0]+1, arr.shape[1])

    result[:arr.shape[0], :] = arr

    result[arr.shape[0], :] = value

    return result

我的 append_2d_numba.py 模块是:

import numba as nb
import numpy as np


@nb.jit(nopython=True)
def append_2d(arr, value):

    result = np.empty((arr.shape[0]+1, arr.shape[1]), dtype=arr.dtype)
    result[:-1] = arr
    result[-1] = value
    return result

我正在将 append_2d 的 Numba 和 Cython 版本与此脚本进行比较:

import pyximport
import numpy as np
pyximport.install(setup_args={'include_dirs': np.get_include()})

from timer import Timer
from append_2d_cython import append_2d as append_2d_cython
from append_2d_numba import append_2d as append_2d_numba

arr_2d = np.random.randint(0, 100, size=(5, 4), dtype=np.int32)
arr_1d = np.array([0, 1, 2, 3], np.int32)

num_tests = 100000

with Timer('append_2d_cython'):
    for _ in range(num_tests):
        r_cython = append_2d_cython(arr_2d, arr_1d)

# # JIT Compile it
append_2d_numba(arr_2d, arr_1d)

with Timer('append_2d_numba'):
    for _ in range(num_tests):
        r_numba = append_2d_numba(arr_2d, arr_1d)

打印:

make many with cython Took 0.36s seconds
make many with numba Took 0.12s seconds

因此,对于这段代码,numba 比 Cython 快 3 倍。我想将 Cython 代码重构为至少与 Numba 代码一样快。我该怎么做?

这项调查将表明,Cython 的大开销是 Cython 性能不佳的原因。此外,将提出一个(有点古怪的)替代方案来避免其中的大部分 - 因此 numba 解决方案将被因素 4 击败。

让我们先在我的机器上建立基线(我调用你的函数 cy_append_2dnb_append_2d 并使用 %timeit 魔法来测量 运行 次) :

arr_2d = np.arange(5*4, dtype=np.int32).reshape((5,4))
arr_1d = np.array([0, 1, 2, 3], np.int32)

%timeit cy_append_2d(arr_2d, arr_1d)
# 8.27 µs ± 141 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit nb_append_2d(arr_2d, arr_1d)
# 2.84 µs ± 169 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Numba 版本大约快三倍 - 与您观察到的时间相似。

但是,我们必须知道,我们衡量的不是复制数据所需的时间,而是开销。它不像 numba 在做一些花哨的事情——它只是碰巧有更少的开销(但仍然很多——创建一个 numpy 数组和复制 24 个整数几乎需要 3 微秒!)

如果我们增加复制的数据量,我们会发现 cython 和 numba 的性能非常相似 - 没有花哨的编译器可以大大改善复制:

N=5000
arr_2d_large = np.arange(5*N, dtype=np.int32).reshape((5,N))
arr_1d_large = np.arange(N, dtype=np.int32)
%timeit cy_append_2d(arr_2d_large, arr_1d_large)
# 35.7 µs ± 597 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit nb_append_2d(arr_2d_large, arr_1d_large)
# 44.8 µs ± 1.36 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

此处 Cython 稍快一些,但对于不同的机器和不同的大小,这可能会有所不同,出于我们的目的,我们可以认为它们几乎同样快。

正如@DavidW 所指出的,从 numpy 数组中的 cython-ndarray 创建 cython-arrays 会带来相当大的开销。考虑这个虚拟函数:

%%cython
cpdef dummy(int[:, :] arr, int[:] value):
    pass

%timeit dummy(arr_2d, arr_1d)
# 3.24 µs ± 47.5 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

这意味着在函数中的第一个操作开始之前,原来的 8µs 中有 3 个已经用完了 - 在这里您可以看到创建内存视图的成本。

通常情况下,人们不会关心这种开销 - 因为如果您为如此小的数据块调用 numpy 功能,无论如何性能都不会很好。


然而,如果你真的喜欢这种微优化,你可以直接使用 Numpy 的 C-API,而无需使用 Cythons ndarray-helper。我们不能指望结果会像复制 24 个整数一样快 - 因为创建一个新的 buffer/numpy-array 只是代价高昂,但是我们击败 8µs 的机会非常高!

这是一个原型,展示了可能的可能性:

%%cython
from libc.string cimport memcpy

# don't use Cythons wrapper, because it uses ndarray
# define only the needed stuff
cdef extern from "numpy/arrayobject.h":
    ctypedef int npy_intp            # it isn't actually int, but cython doesn't care anyway
    int _import_array() except -1
    char *PyArray_BYTES(object arr)
    npy_intp PyArray_DIM(object arr, int n)
    object PyArray_SimpleNew(int nd, npy_intp* dims, int typenum)
    cdef enum NPY_TYPES:
        NPY_INT32

# initialize Numpy's C-API when imported.
_import_array()  

def cy_fast_append_2d(upper, lower):
    # create resulting array:
    cdef npy_intp dims[2]
    dims[0] = PyArray_DIM(upper, 0)+1
    dims[1] = PyArray_DIM(upper, 1)
    cdef object res = PyArray_SimpleNew(2, &dims[0], NPY_INT32)
    # copy upper array, assume C-order/memory layout
    cdef char *dest = PyArray_BYTES(res)
    cdef char *source = PyArray_BYTES(upper)
    cdef int size = (dims[0]-1)*dims[1]*4 # int32=4 bytes
    memcpy(dest, source, size)
    # copy lower array, assume C-order/memory layout
    dest += size
    source = PyArray_BYTES(lower)
    size = dims[1]*4
    memcpy(dest, source, size)
    return res

现在的时间是:

%timeit cy_fast_append_2d(arr_2d, arr_1d)
753 ns ± 3.13 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

这意味着 Cython 以 4 倍的优势击败 Numba。

但是,有很多安全损失 - 例如,它仅适用于 C 顺序数组而不适用于 Fortran 顺序数组。但我的目标不是提供防水解决方案,而是研究直接使用 Numpy 的 C-API 可能变得多快 - 是否应该采用这种 hacky 方式由您决定。