为什么Numba如此快?

7

我想编写一个函数,它将获取形状为(N_ROWS,)的索引lefts,并创建一个矩阵out = (N_ROWS, N_COLS),其中out[i, j] = 1当且仅当j >= lefts[i]。以下是在循环中执行此操作的简单示例:

class Looped(Strategy):
    def copy(self, lefts):
        out = np.zeros([N_ROWS, N_COLS])
        for k, l in enumerate(lefts): 
            out[k, l:] = 1
        return out

现在我希望它尽可能快,因此我有不同实现该函数的方法:

  1. 纯Python循环
  2. 使用@njit的numba
  3. 一个纯C++实现,我用ctypes调用

以下是100次运行的平均结果:

Looped took 0.0011599776260009093
Numba took 8.886413300206186e-05
CPP took 0.00013200821400096175

Numba的运行速度比下一个最快的实现——C++快约1.5倍,为什么呢?

  • 在类似的问题中,我听说Cython速度较慢是因为它没有使用所有优化标志进行编译,但是C++实现是用-O3编译的,这样足以让我获得编译器可以给出的所有可能的优化吗?
  • 我不完全理解如何把NumPy数组传递给C++,我是否无意中复制了数据?

# numba implementation

@njit
def numba_copy(lefts):
    out = np.zeros((N_ROWS, N_COLS), dtype=np.float32)
    for k, l in enumerate(lefts): 
        out[k, l:] = 1.
    return out

    
class Numba(Strategy):
    def __init__(self) -> None:
        # avoid compilation time when timing 
        numba_copy(np.array([1]))

    def copy(self, lefts):
        return numba_copy(lefts)


// array copy cpp

extern "C" void copy(const long *lefts,  float *outdatav, int n_rows, int n_cols) 
{   
    for (int i = 0; i < n_rows; i++) {
        for (int j = lefts[i]; j < n_cols; j++){
            outdatav[i*n_cols + j] = 1.;
        }
    }
}

// compiled to a .so using g++ -O3 -shared -o array_copy.so array_copy.cpp

# using cpp implementation

class CPP(Strategy):

    def __init__(self) -> None:
        lib = ctypes.cdll.LoadLibrary("./array_copy.so")
        fun = lib.copy
        fun.restype = None
        fun.argtypes = [
            ndpointer(ctypes.c_long, flags="C_CONTIGUOUS"),
            ndpointer(ctypes.c_float, flags="C_CONTIGUOUS"),
            ctypes.c_long,
            ctypes.c_long,
            ]
        self.fun = fun

    def copy(self, lefts):
        outdata = np.zeros((N_ROWS, N_COLS), dtype=np.float32, )
        self.fun(lefts, outdata, N_ROWS, N_COLS)
        return outdata

带有时间等信息的完整代码:

import time
import ctypes
from itertools import combinations

import numpy as np
from numpy.ctypeslib import ndpointer
from numba import njit


N_ROWS = 1000
N_COLS = 1000


class Strategy:

    def copy(self, lefts):
        raise NotImplementedError

    def __call__(self, lefts):
        s = time.perf_counter()
        n = 1000
        for _ in range(n):
            out = self.copy(lefts)
        print(f"{type(self).__name__} took {(time.perf_counter() - s)/n}")
        return out


class Looped(Strategy):
    def copy(self, lefts):
        out = np.zeros([N_ROWS, N_COLS])
        for k, l in enumerate(lefts): 
            out[k, l:] = 1
        return out


@njit
def numba_copy(lefts):
    out = np.zeros((N_ROWS, N_COLS), dtype=np.float32)
    for k, l in enumerate(lefts): 
        out[k, l:] = 1.
    return out


class Numba(Strategy):
    def __init__(self) -> None:
        numba_copy(np.array([1]))

    def copy(self, lefts):
        return numba_copy(lefts)


class CPP(Strategy):

    def __init__(self) -> None:
        lib = ctypes.cdll.LoadLibrary("./array_copy.so")
        fun = lib.copy
        fun.restype = None
        fun.argtypes = [
            ndpointer(ctypes.c_long, flags="C_CONTIGUOUS"),
            ndpointer(ctypes.c_float, flags="C_CONTIGUOUS"),
            ctypes.c_long,
            ctypes.c_long,
            ]
        self.fun = fun

    def copy(self, lefts):
        outdata = np.zeros((N_ROWS, N_COLS), dtype=np.float32, )
        self.fun(lefts, outdata, N_ROWS, N_COLS)
        return outdata


def copy_over(lefts):
    strategies = [Looped(), Numba(), CPP()]

    outs = []
    for strategy in strategies:
        o = strategy(lefts)
        outs.append(o)

    for s_0, s_1 in combinations(outs, 2):
        for a, b in zip(s_0, s_1):
            np.testing.assert_allclose(a, b)
    

if __name__ == "__main__":
    copy_over(np.random.randint(0, N_COLS, size=N_ROWS))


哇,如果使用numba可以让Python比手写的C++更快,那太棒了! - user17242583
5
说实话,你正在比较一个可能经过几个月、甚至几年高度优化的软件包与你尝试编写的基本上是双重循环的程序。毫无疑问,谁会赢得这场竞赛。 - PaulMcKenzie
一些基本的优化加上正确的编译器标志可能会提高C++性能:https://godbolt.org/z/Kz3MWvPEd - Alan Birtles
@AlanBirtles 这使我提高了1.28倍。 - Ben
4
Numba实际上比C++代码有优势:在Numba中,“N_ROWS”和“N_COLS”是硬编码常量,而在C ++中则是变量。这可能允许它展开一些循环。 - DavidW
还有许多优化可以做,您可以展开循环并使用simd内置函数。尽可能多地使用常量也将有助于优化器。 - Alan Birtles
2个回答

9
Numba目前使用LLVM-Lite将代码高效地编译成二进制文件(在Python代码被翻译为LLVM中间表示后)。该代码使用类似于使用Clang编译的C++代码的优化标志-O3-march=native进行优化。最后一个参数非常重要,因为它使LLVM能够在相对较新的x86-64处理器上使用更广泛的SIMD指令:AVX和AVX2 (可能还包括最近的英特尔处理器AVX512)。否则,默认情况下,Clang和GCC只使用SSE/SSE2指令(由于向后兼容性)。
另一个区别来自于GCC和Numba中LLVM代码之间的比较。Clang/LLVM倾向于积极展开循环,而GCC通常不这样做。这对生成的程序有显著的性能影响。实际上,您可以看到来自Clang的生成的汇编代码:
使用Clang (每个循环128个项目):
.LBB0_7:
        vmovups ymmword ptr [r9 + 4*r8 - 480], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 448], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 416], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 384], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 352], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 320], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 288], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 256], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 224], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 192], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 160], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 128], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 96], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 64], ymm0
        vmovups ymmword ptr [r9 + 4*r8 - 32], ymm0
        vmovups ymmword ptr [r9 + 4*r8], ymm0
        sub     r8, -128
        add     rbp, 4
        jne     .LBB0_7

使用GCC(每个循环8个项目):

.L5:
        mov     rdx, rax
        vmovups YMMWORD PTR [rax], ymm0
        add     rax, 32
        cmp     rdx, rcx
        jne     .L5

因此,为了公平起见,您需要将使用Numba编写的代码与使用Clang编译的C++代码进行比较,并应用上述优化标志。
请注意,根据您的需求和最后级处理器缓存的大小,您可以使用非暂态存储(NT存储)编写更快的平台特定C++代码。NT存储会告诉处理器不要将数组存储在其缓存中。使用NT存储来写入数据时,将大量数组写入RAM的速度更快,但是如果数组可以适合缓存(因为数组必须从RAM重新加载),则在复制之后读取已存储的数组可能会变慢。在您的情况下(4 MiB的数组),这是否更快尚不清楚。

1
如果有人使用MSVC编译器,标志是不同的:/O2用于优化,关于架构则是/arch:AVX2/arch:AVX512这是编译器选项的完整列表 - Zaero Divide

3

结合其他答案/评论的建议,我比Numba表现更好,具体方法如下:

  • 使用cython + memoryviews(使用ctypes时会有一些开销)
  • 优化cpp实现
  • 将cython编译器切换为clang,并设置-march = skylake

现在,我的效果更好了。

CPP took 9.407872100018721e-05
Numba took 9.336918499957392e-05
Cythonised took 9.22323310005595e-05

// array_copy.cpp

#include "array.h" 

const int n_rows = 1000;
const int n_cols = 1000;

void copy(const long *lefts,  float *outdatav) 
{   
    const float one = 1.;
    for (int i = 0; i < n_rows; i++) {
        const int l = lefts[i];
        float* start = outdatav + i*n_cols + l;
        std::fill(start, start + n_cols - l, one);
    }
}


# setup.py
import os

os.environ["CXX"] = "clang"
os.environ["CC"] = "clang"

from setuptools import setup, Extension
from Cython.Build import cythonize

ex = Extension(
    "array_copy_cython", 
    ["./cpp_ext/array_copy_ext.pyx", "./cpp_ext/array.cpp" ],
    include_dirs=["./cpp_ext"],
    extra_compile_args=["-march=skylake"],
    language="c++")
    

setup(
    name='copy',
    ext_modules=cythonize(ex),
    zip_safe=False,
)

# array_copy_ext.pyx

cdef extern from "array.h":
    void copy(const long* lefts,  float* outdatav)

cimport cython
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.initializedcheck(False)
def copy_array(const long[:] lefts, float[:,:] outdatav):
    copy(&lefts[0], &outdatav[0][0])
    return outdatav

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