Cython 中的数组求和数组

Ted*_*rou 1 numpy cython

我试图找到使用 Cython 对 numpy 数组进行水平求和的最快方法。首先,假设我有一个 10 x 100,000 随机浮点数的 2D 数组。我可以创建一个object数组,每列作为数组中的一个值,如下所示:

n = 10 ** 5
a = np.random.rand(10, n)
a_obj = np.empty(n, dtype='O')
for i in range(n):
    a_obj[i] = a[:, i]
Run Code Online (Sandbox Code Playgroud)

我想做的就是找到每一行的总和。它们都可以这样简单地计算:

%timeit a.sum(1)
414 µs ± 24.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit a_obj.sum()
113 ms ± 7.01 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

对象数组慢 250 倍。

在尝试总结之前,我想对每个元素的访问时间进行计时。Cython 在直接遍历每个项目时无法加速访问对象数组的每个成员:

def access_obj(ndarray[object] a):
    cdef int i
    cdef int nc = len(a)
    cdef int nr = len(a[0])
    for i in range(nc):
        for j in range(nr):
            a[i][j]

%timeit access_obj(a_obj)
42.1 ms ± 665 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

但是,我相信我可以使用data作为memoryview对象的属性访问底层 C 数组并获得更快的访问:

def access_obj_data(ndarray[object] a):
    cdef int i
    cdef int nc = len(a)
    cdef int nr = len(a[0])
    cdef double **data = <double**>a.data
    for i in range(nc):
        for j in range(nr):
            data[i][j]
Run Code Online (Sandbox Code Playgroud)

我认为这会在计时时缓存结果,所以我不得不做一次。

%timeit -n 1 -r 1 access_obj_data(a_obj)
8.17 µs ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
Run Code Online (Sandbox Code Playgroud)

问题是你不能只是a.data转换成一个 C 数组的双打。如果我打印出第一列,我会得到以下信息。

5e-324
2.1821467023e-314
2.2428810855e-314
2.1219957915e-314
6.94809615162997e-310
6.94809615163037e-310
2.2772194067e-314
2.182150145e-314
2.1219964234e-314
0.0
Run Code Online (Sandbox Code Playgroud)

有类似的问题,但没有明确回答使用有效代码快速执行此操作的方法。

ead*_*ead 5

我想有两个不同的问题:

  1. 为什么a_obj.sum()这么慢?
  2. 为什么cython-function代码很慢?

我们将看到a_obj.sum()可以跟踪 Python-dispatch + 缓存未命中的开销。然而,这是一个相当多余的分析的结果,可能还有更微妙的原因。

第二个问题不太有趣:我们将看到,Cython 对数组中的对象的了解不够,无法加快函数的运行速度。

还有一个问题“最快的方法是什么?”。答案是“这取决于您的数据和硬件”,因为对于此类问题几乎总是如此。

但是,我们将使用获得的见解对现有版本进行略微改进。


让我们从“a_obj.sum()很慢”开始。

作为我机器上示例的基准(是的,因子200):

>>> %timeit a.sum(1)
510 µs ± 14.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit a_obj.sum()
90.3 ms ± 494 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

首先发生了a_obj.sum()什么?归约算法如下:

result=a_obj[0]+a_obj[1]+a_obj[2]+...
Run Code Online (Sandbox Code Playgroud)

然而,算法不知道那a_obj[i]是numpy-arrays,所以它必须使用Python-dispatch才能知道,这a_obj[i]+a_obj[i+1]实际上是两个numpy-arrays的相加。总结10我们必须支付10**5时间的双打,这是一个相当大的开销。前段时间我回答了一个关于Python-dispatch的成本的问题,如果你有兴趣了解更多的话。

如果我们的数组的形状是10**5x10,我们将支付的开销只有10在与10**5加法的工作相比不会很大的时候:

>>> b=np.random.rand(n,10)
>>> b_obj=to_obj_array(b) # see listing at the end for code
>>> %timeit b.sum(1)
2.43 ms ± 13.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)    
>>> %timeit b_obj.sum()
5.53 ms ± 17.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Run Code Online (Sandbox Code Playgroud)

如您所见,因子(约 2)现在要小得多。然而,Python 调度开销理论并不能解释一切。

让我们来看看以下数组的大小:

>>> c=np.random.rand(10**4,10**4)
>>> c_obj=to_obj_array(c)
>>> %timeit c.sum(1)
52.5 ms ± 354 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> %timeit c_obj.sum()
369 ms ± 2.53 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Run Code Online (Sandbox Code Playgroud)

这次系数大约是8!原因是 obj 版本中的缓存未命中。这是一项受内存限制的任务,因此基本上内存速度是瓶颈。

在 numpy 数组中,元素连续存储在内存中,一行接一行。每次从内存中提取一个双精度值时,它都会与 7 个双精度邻居一起提取,这 8 个双精度值保存在缓存中。

因此,当c.sum(1)运行时需要逐行获取信息:每次从内存中将双精度数提取到缓存中时,会预取接下来的 7 个数字,因此可以将它们相加。这是最有效的内存访问模式。

不同的对象版本。该信息是按列需要的。因此,每次我们将额外的7双精度数提取到缓存中时,我们还不需要它,而在我们可以处理这些双精度数时,它们已经从缓存中逐出(因为缓存不足以容纳列中的所有数字)并且需要再次从慢速内存中获取。

这意味着使用第二种方法,每个双精度值将从内存中读取 8 次,或者从内存/缓存未命中中读取 8 倍。

我们可以很容易地验证使用 valgrind 的cachegrind来测量两个变体的缓存未命中(请参阅最后的 test_cache.py 清单):

valgrind --tool=cachegrind python test_cache.py array
...
D1  misses:       144,246,083 
...

valgrind --tool=cachegrind python test_cache.py object
...
D1  misses:     1,285,664,578
...
Run Code Online (Sandbox Code Playgroud)

c_obj-version 有大约 8 倍的 D1-cache-misses。


但还有更多乐趣,对象版本可能会更快!

>>> d=np.random.rand(800,6)
>>> d_obj=to_obj_array(d)
>>> %timeit d.sum(1)
20.2 µs ± 217 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
>>> %timeit d_obj.sum()
12.4 µs ± 155 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
Run Code Online (Sandbox Code Playgroud)

怎么会这样?它们可能很快相似,因为

  1. 整个数组可以保存在缓存中,因此没有额外的缓存未命中。
  2. python-dispatch 开销是可以承受的。

但是对于其他较慢的版本,速度要快两倍?

我不是 100% 确定,但这是我的理论:对于数组版本,我们计算行的总和i

...
sum_i=sum_i+a[i][j]
sum_i=sum_i+a[i][j+1]
sum_i=sum_i+a[i][j+2]
...
Run Code Online (Sandbox Code Playgroud)

所以硬件不能并行执行指令,因为需要上一个操作的结果来计算下一个。

但是对于对象版本,计算如下:

...
sum_i=sum_i+a[i][j]
sum_k=sum_k+a[k][j]
sum_m=sum_m+a[i][j]
...
Run Code Online (Sandbox Code Playgroud)

这些步骤可以由 CPU 并行完成,因为它们之间没有依赖性。


那么,为什么 cython 这么慢?因为 cython 对数组中的对象一无所知,所以它并不比通常的 Python 代码快,这非常慢!

让我们分解一下 cython-function access_obj

  1. Cython 一无所知,a[i]并假设它是一个通用的 Python 对象。
  2. 这意味着,a[i][j]它必须调用__get_item__a[i]与Python基础设施的帮助。顺便提一句。因为j必须将其转换为成熟的 Python 整数。
  3. __get_item__ 从存储的 c-double 构造一个完整的 Python-float 并将其返回给 cython。
  4. cast 将 Python-float 返回到 c-float。

在底线之下,我们使用 Python-dispatch 并创建了两个临时 Python 对象 - 您可以看到所需的成本。


加速求和

我们已经看到,求和是一个内存绑定任务(至少在我的机器上),所以最重要的是避免缓存未命中(这也可能意味着,例如,我们需要不同的 C 算法- order 和 F-order numpy-arrays)。

然而,如果我们的任务必须与一个 CPU 重载任务共享一个 CPU,它可能会再次成为事实上的 CPU 绑定,所以它有一些优点,不仅仅是关注现金失误。

在稍后介绍的 C 版本中,我们希望结合两种方法的优点:

  1. 与 object-version 类似,我们希望并行计算多个行总和,以便能够使用 CPU 的内在并行性。
  2. 与 numpy-array-sum 类似,我们希望缓存未命中数最少,因此我们不是同时计算所有行,而是只计算一个小块(例如 8)。这种逐块计算的优点是,一旦缓存的数据在真正需要之前不会从缓存中逐出。

这是一个简化版本,它以 8 个为一组处理行:

//fast.c
#define MAX 8
void sum_rows_blocks(double *orig, double *out, int n_rows, int n_cols){
   int n_blocks=n_rows/MAX;
   for (int b=0; b<n_blocks; b++){   //for every block
       double res[MAX]={0.0};        //initialize to 0.0
       for(int j=0;j<n_cols;j++){
           for(int i=0;i<MAX;i++){   //calculate sum for MAX-rows simultaniously
              res[i]+=orig[(b*MAX+i)*n_cols+j];
           }
       }
       for(int i=0;i<MAX;i++){
             out[b*MAX+i]=res[i];
       }
   }  
   //left_overs:
   int left=n_rows-n_blocks*MAX;
   double res[MAX]={0.0};         //initialize to 0.0
   for(int j=0;j<n_cols;j++){
       for(int i=0;i<left;i++){   //calculate sum for left rows simultaniously
              res[i]+=orig[(n_blocks*MAX+i)*n_cols+j];
       }
   }
   for(int i=0;i<left;i++){
         out[n_blocks*MAX+i]=res[i];
   }
}
Run Code Online (Sandbox Code Playgroud)

现在我们可以使用 cython 来包装这个函数:

//c_sum.pyx
cimport numpy as np    
import numpy as np

cdef extern from "fast.c":
    void sum_rows_blocks(double *orig, double *out, int n_rows, int n_cols)

def sum_rows_using_blocks(double[:,:] arr):
    cdef int n_rows
    cdef int n_cols
    cdef np.ndarray[np.double_t] res
    n_rows=arr.shape[0] 
    n_cols=arr.shape[1]
    res=np.empty((n_rows, ),'d')
    sum_rows_blocks(&arr[0,0], &res[0], n_rows, n_cols)
    return res
Run Code Online (Sandbox Code Playgroud)

在构建扩展后(参见 清单setup.py),我们可以进行比较:

import c_sum
%timeit c_sum.sum_rows_using_blocks(d) #shape=(800,6)
4.26 µs ± 95.8 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
Run Code Online (Sandbox Code Playgroud)

3比迄今为止最快的版本(12 µs)更快。大型数组的表现如何:

%timeit c_sum.sum_rows_using_blocks(c) #shape=(10**4,10**4)
49.7 ms ± 600 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

好,稍微快于c.sum(1)这需要52毫秒,这意味着我们没有额外的缓存未命中。

可以改进吗?我确实这么认为:例如,不使用默认编译标志-fwrapv(通过包含extra_compile_args = ["-fno-wrapv"]在设置中)将改进生成的程序集并导致 shape 求和的 10% 加速(800, 6)


房源:

def to_obj_array(a):   
     n=a.shape[1]
     obj=np.empty((n,),dtype='O')
     for i in range(n):
          obj[i]=a[:,i]
     return obj
Run Code Online (Sandbox Code Playgroud)

check_cache.py

import sys
import numpy as np

def to_obj_array(a):   
     n=a.shape[1]
     obj=np.empty((n,),dtype='O')
     for i in range(n):
          obj[i]=a[:,i]
     return obj

c=np.random.rand(10**4,10**4)
c_obj=to_obj_array(c)


for i in range(10):
  if sys.argv[1]=="array":
     c.sum(1)
  elif sys.argv[1]=="object":
     c_obj.sum()
  else:
     raise Exception("unknown parameter")
Run Code Online (Sandbox Code Playgroud)

setup.py

#setup.py
from distutils.core import setup, Extension
from Cython.Build import cythonize
import numpy as np

setup(ext_modules=cythonize(Extension(
            name='c_sum',
            sources = ["c_sum.pyx"],
            #extra_link_args = ["fast.o"],
            include_dirs=[np.get_include()]

    )))
Run Code Online (Sandbox Code Playgroud)