在 Cython 中循环二维数组的最快方法

Fastest way to do loop over 2D arrays in Cython

我正在尝试在 Cython 中循环遍历 2 个二维数组。阵列具有以下形状: ranges_1int64 的 6000x3 数组,而 ranges_2int64 的 2000x2 数组。此迭代需要执行大约 10000 次。这意味着嵌套 for 循环内的计算总数将约为 2000x6000x10000 = 1200 亿次。

这是我用来生成“虚拟”数据的代码:

import numpy as np
ranges_1 = np.stack([np.random.randint(0, 10_000, 6_000), np.random.randint(0, 10_000, 6_000), np.arange(0, 6_000)], axis=1)
ranges_2 = np.stack([np.random.randint(0, 10_000, 2_000), np.random.randint(0, 10_000, 2_000)], axis=1)

这给出了 2 个这样的数组:

array([[6131, 1478,    0],
       [9317, 7263,    1],
       [7938, 6249,    2],
       ...,
       [5153,  426, 5997],
       [9164, 9211, 5998],
       [1695, 1792, 5999]])

和:

array([[ 433,  558],
       [3420, 2494],
       [6367, 7916],
       ...,
       [8693, 1692],
       [1256, 9013],
       [4096, 1860]])

我尝试的第一个实现是一个“朴素”的版本,它如下(里面的函数只是一个测试函数,它使用数组中的所有数据):

import numpy as np
cimport numpy as np
cimport cython
ctypedef np.int_t DTYPE_t

def test_func(np.ndarray[DTYPE_t, ndim = 2] ranges_1, np.ndarray[DTYPE_t, ndim=2] ranges_2,  int n ):
    k = 0 
    for i in range(n):

        for j in range(len(ranges_1)):
            r1 = ranges_1[j]
            a = r1[0]
            b = r1[1]
            c = r1[2]

            for f in range(len(ranges_2)):
                r2 = ranges_2[f]
                d = r2[0]
                e = r2[1]

                k = (a + b + c + d + e)/(d+e)
            
    return k

每个 10_000 外部循环大约需要 5 秒。 所以我然后尝试展平数组,因为我知道另一个轴上的维度,所以访问这样的项目:

import numpy as np
cimport numpy as np
cimport cython
ctypedef np.int_t DTYPE_t

def test_func_flattened(np.ndarray[DTYPE_t, ndim = 1] ranges_1_, np.ndarray[DTYPE_t, ndim=1] ranges_2_,  int n ):
    k = 0 
    for i in range(n):

        for j in range(0, len(ranges_1_), 3):
            a = ranges_1_[j]
            b = ranges_1_[j+1]
            c = ranges_1_[j+2]
                
            for f in range(0, len(ranges_2_), 2):
                d = ranges_2_[f]
                e = ranges_2_[f+1]

                k = (a + b + c + d + e)/(d+e)
            
    return k

但这根本没有提供任何加速。执行 10_000 的单次迭代的时间似乎太长,考虑到对于单次迭代它只是循环内的 12_000_000 操作。我还尝试在 Cython 和 python 中实现一个更简单的示例,然后用 numba:

编译
import numpy as np
cimport numpy as np
cimport cython
ctypedef np.int_t DTYPE_t

def test_1(int n ):
    cdef k = 0 
    cdef a = 0
    for i in range(n):
        a =  i +1
            
    return a

这花了 15 秒到 运行,n = 1_000_000_000。

同时 numba:

def test_1_python(n ):
    k = 0 
    a = 0
    
    for i in range(n):
        if i % 2 == 0:
            a = a + 1
        else:
            a = a - 1
            
    return a

test_1_numba= numba.jit(test_1_python)

%%time
test_1_numba(120_000_000_000)

完整的运行 n = 12000亿只用了大约6s,(虽然里面的函数更简单)这意味着它比Cython快500倍,这可能吗?

我是 Cython 的新手,所以我可能遗漏了一些明显的东西,但是由于 numba 版本(没有数组访问)快得多,我认为速度上的差异可能来自与访问项目相关的开销在数组中。

这是一个错误的假设吗?

如果不是,那么在 Cython 中循环遍历二维整数列表的最佳方法是什么?

您在基准测试中衡量的主要是编译人工制品和开销

首先,Cython 使用 Python 堆栈首选安装在您机器上的默认编译器。在Linux上,应该是GCC。在 Windows 上,如果安装了肯定是 MSVC,否则是 MinGW(如果有的话)。同时,Numba 是基于 LLVM-Lite 的,它像 Clang 一样基于 LLVM 堆栈。因此,在您的情况下,很可能使用 不同的编译器 导致不同的二进制文件具有不同的性能。如果你想做一个公平的基准测试,你需要使用 Clang 来构建你的 Cython 程序。

此外,Cython 的默认优化是 -O2 而 Numba 是 -O3。前者不应启用 auto-vectorization 而后者应启用(这取决于目标编译器——较新版本的 GCC 会更改此行为)。此外,Cython 默认不启用 machine-specific non-portable 优化(因为二进制文件可能会像 pip 一样为其他机器打包)。这意味着 Cython 只能在 x86-64 处理器上默认使用旧的 SSE2 SIMD 指令集。同时,LLVM-JIT 可以使用更快的 AVX2/AVX-512 SIMD 指令集。您需要使用 Cython 手动启用此类优化,以便基准测试公平(即 GCC/Clang 上的 -march=native)。

事实上,在我的 x86-64 主流 Intel 机器上,Numba 确实使用了 AVX2 指令集,而 Cython 在您上次的基准测试中没有使用。例如,这里是 Numba JIT 生成的主循环:

.LBB0_7:
        vptestnmq       %ymm5, %ymm4, %k1
        vpblendmq       %ymm5, %ymm6, %ymm18 {%k1}
        vpaddq  %ymm0, %ymm18, %ymm0
        vpaddq  %ymm1, %ymm18, %ymm1
        vpaddq  %ymm2, %ymm18, %ymm2
        vpaddq  %ymm3, %ymm18, %ymm3
        vpaddq  %ymm16, %ymm4, %ymm18
        vptestnmq       %ymm5, %ymm18, %k1
        vpblendmq       %ymm5, %ymm6, %ymm18 {%k1}
        vpaddq  %ymm0, %ymm18, %ymm0
        vpaddq  %ymm1, %ymm18, %ymm1
        vpaddq  %ymm2, %ymm18, %ymm2
        vpaddq  %ymm3, %ymm18, %ymm3
        vpaddq  %ymm17, %ymm4, %ymm4
        addq    $-2, %rdx
        jne     .LBB0_7

对于在循环中执行 a = i + 1 的基准测试,它是 有缺陷的 因为一个好的编译器可以优化整个循环(即删除它)并替换它只有一个赋值,因为 只有最后一次迭代很重要 。事实上,同样的事情也适用于 k = (a + b + c + d + e)/(d+e):只有最后一次迭代很重要。 for i in range(n)i 变量甚至没有使用。 Clang 和 GCC 经常做这样的优化。

最后,如果修改为在 real-world use-case使用多线程

请注意,除法非常昂贵,您可以预先计算倒数,以便在主循环中执行乘法。