为什么在迭代 NumPy 数组时,Cython 比 Numba 慢得多?

20

在迭代NumPy数组时,Numba似乎比Cython快得多。
我可能错过了哪些Cython优化?

这里是一个简单的例子:

纯Python代码:

import numpy as np

def f(arr):
  res=np.zeros(len(arr))
   
  for i in range(len(arr)):
     res[i]=(arr[i])**2
    
  return res

arr=np.random.rand(10000)
%timeit f(arr)

输出:每次循环的平均值为4.81毫秒,标准差为72.2微秒(基于7次运行,每次100个循环)


Jupyter中的Cython代码:

%load_ext cython
%%cython

import numpy as np
cimport numpy as np
cimport cython
from libc.math cimport pow

#@cython.boundscheck(False)
#@cython.wraparound(False)

cpdef f(double[:] arr):
   cdef np.ndarray[dtype=np.double_t, ndim=1] res
   res=np.zeros(len(arr),dtype=np.double)
   cdef double[:] res_view=res
   cdef int i

   for i in range(len(arr)):
      res_view[i]=pow(arr[i],2)
    
   return res

arr=np.random.rand(10000)
%timeit f(arr)

输出: 每次循环445微秒 ± 5.49微秒 (7次运行的平均值±标准差, 每次循环1000次)


Numba代码:

import numpy as np
import numba as nb

@nb.jit(nb.float64[:](nb.float64[:]))
def   f(arr):
   res=np.zeros(len(arr))
   
   for i in range(len(arr)):
       res[i]=(arr[i])**2
    
   return res

arr=np.random.rand(10000)
%timeit f(arr)
Out: 本例中,Numba比Cython快近50倍。作为一个Cython初学者,我猜我错过了什么。 在这种简单的情况下,使用NumPy的向量化函数square会更加合适。
%timeit np.square(arr)

输出:每个循环5.75微秒,标准偏差为78.9纳秒(7次运行的平均值±标准偏差,每次100,000次循环)


3
为什么你在Cython代码中不直接写成arr[i]**2呢?我认为可能的原因是,pow(arr[i],2)会将数字2视为float类型,从而使计算变得更加复杂。 - Antonio Ragagnin
谢谢,但我也尝试过使用arr[i]**2而不是pow(arr[i],2),两种解决方案的性能几乎相同。通常情况下,即使是对numpy数组进行简单迭代而没有数学转换,numba编译函数的运行速度也比cython快。 - Greg A
1个回答

32

正如 @Antonio 指出的那样,使用 pow 进行简单乘法并不明智,会导致相当大的开销:

因此,将 pow(arr[i], 2) 替换为 arr[i]*arr[i] 可以大幅提高速度:

cython-pow-version        356 µs
numba-version              11 µs
cython-mult-version        14 µs

剩下的差异可能是由于编译器和优化级别之间的差异(例如在我的情况下,llvm与MSVC的区别)。您可能希望使用clang来匹配numba的性能(例如,请参见此SO-answer

为了使编译器更容易进行优化,您应该将输入声明为连续数组,即double[::1] arr(请参见此问题,解释为什么这对矢量化很重要),使用@cython.boundscheck(False)(使用选项-a可以看到黄色变少),还应添加编译器标志(例如-O3-march=native或类似的标志,具体取决于编译器,以启用矢量化。注意默认使用的构建标志可能会阻止某些优化,例如-fwrapv)。最后,您可能希望用C编写工作马循环,使用正确组合的标志/编译器进行编译,并使用Cython进行包装。

顺便说一下,通过将函数参数类型定义为nb.float64[:](nb.float64[:]),您会降低numba的性能 - 它不再可以假设输入数组是连续的,从而排除了矢量化。让numba检测类型(或定义为连续,即nb.float64[::1](nb.float64[::1]),您将获得更好的性能:

@nb.jit(nopython=True)
def nb_vec_f(arr):
   res=np.zeros(len(arr))

   for i in range(len(arr)):
       res[i]=(arr[i])**2

   return res

带来以下改善:

%timeit f(arr)  # numba version
# 11.4 µs ± 137 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit nb_vec_f(arr)
# 7.03 µs ± 48.9 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

正如@max9111所指出的那样,我们不必使用np.zeros(...)初始化结果数组,而是可以使用np.empty(...)。-这个版本甚至超过了numpy的np.square()

在我的设备上,不同方法的性能如下:

numba+vectorization+empty     3µs
np.square                     4µs
numba+vectorization           7µs
numba missed vectorization   11µs
cython+mult                  14µs
cython+pow                  356µs

非常感谢您的见解!有了您的优化,我的Cython函数运行速度几乎与Numba一样快。 - Greg A
5
这并不完全关于这个问题,但有一件小事情被忽略了。在开始时对已分配的数组进行不必要的归零操作,大约占总运行时间的30%以上,并且至少在Numba中没有被编译器优化掉。 - max9111
@ead 这只是出于好奇的一个问题。但是我之前在Cython中使用pow时遇到了相似的问题。如果您不在Numba中硬编码指数并且存在SVML,则会在256位向量上调用SVML的pow函数,结果大约为150微秒。是否有一个简单的替代方案可以在Cython中使用而不使用icc呢? - max9111
1
@max9111,我必须承认我从未尝试过。我可能更愿意用C编写代码并在Cython中包装功能,而不是直接从Cython访问“内部函数”。 - ead

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