性能提示

这是一个关于Numba中可用功能的小指南,这些功能可以帮助从代码中获得最佳性能。使用了两个例子,这两个例子完全是人为设计的,仅出于教学目的存在,以激发讨论。第一个是计算三角恒等式 cos(x)^2 + sin(x)^2,第二个是对一个向量进行逐元素平方根运算并进行求和缩减。所有性能数据仅供参考,除非另有说明,否则均来自在Intel i7-4790 CPU(4个硬件线程)上运行,输入为 np.arange(1.e7)

备注

实现高性能代码的一个相当有效的方法是,使用真实数据运行代码并进行性能分析,然后根据分析结果指导性能调优。这里提供的信息是为了展示功能,而不是作为权威指导!

NoPython 模式

Numba 的 @jit 装饰器默认操作模式是 nopython 模式。这种模式对可编译的内容限制最多,但生成的可执行代码速度更快。

备注

从历史上看(在0.59.0版本之前),默认的编译模式是一种回退模式,编译器会尝试在 nopython模式 下编译,如果失败则会回退到 对象模式。你可能会在代码/文档中看到 @jit(nopython=True) 或其别名 @njit,因为这是强制使用 nopython模式 的推荐最佳实践方法。自Numba 0.59.0版本以来,这已不再必要,因为 nopython模式@jit 的默认模式。

循环

虽然 NumPy 在向量操作的使用上发展出了一套强有力的惯用法,Numba 同样也支持循环。对于熟悉 C 或 Fortran 的用户来说,以这种方式编写 Python 代码在 Numba 中也能正常工作(毕竟,LLVM 在编译 C 系语言时得到了广泛应用)。例如:

@njit
def ident_np(x):
    return np.cos(x) ** 2 + np.sin(x) ** 2

@njit
def ident_loops(x):
    r = np.empty_like(x)
    n = len(x)
    for i in range(n):
        r[i] = np.cos(x[i]) ** 2 + np.sin(x[i]) ** 2
    return r

当用 @njit 装饰时,上述运行速度几乎相同,没有装饰器时,矢量化函数的运行速度要快几个数量级。

函数名称

@njit

执行时间

ident_np

0.581秒

ident_np

0.659秒

ident_loops

25.2秒

ident_loops

0.670秒

对象模式的案例:循环提升

某些函数可能与限制性的 nopython 模式 不兼容,但包含兼容的循环。你可以通过设置 @jit(forceobj=True) 来启用这些函数,尝试在其循环上使用 nopython 模式。不兼容的代码段将在对象模式下运行。

虽然在对象模式下使用循环提升可以提供一些性能提升,但完全在 nopython 模式 下编译函数是实现最佳性能的关键。

快速数学

在某些类型的应用程序中,严格的 IEEE 754 合规性不那么重要。因此,为了获得额外的性能,可以放宽一些数值严谨性。在 Numba 中实现这种行为的方法是使用 fastmath 关键字参数:

@njit(fastmath=False)
def do_sum(A):
    acc = 0.
    # without fastmath, this loop must accumulate in strict order
    for x in A:
        acc += np.sqrt(x)
    return acc

@njit(fastmath=True)
def do_sum_fast(A):
    acc = 0.
    # with fastmath, the reduction can be vectorized as floating point
    # reassociation is permitted.
    for x in A:
        acc += np.sqrt(x)
    return acc

函数名称

执行时间

do_sum

35.2 毫秒

do_sum_fast

17.8 毫秒

在某些情况下,您可能希望仅选择部分可能的快速数学优化。这可以通过向 fastmath 提供一组 LLVM 快速数学标志 来实现。:

def add_assoc(x, y):
    return (x - y) + y

print(njit(fastmath=False)(add_assoc)(0, np.inf)) # nan
print(njit(fastmath=True) (add_assoc)(0, np.inf)) # 0.0
print(njit(fastmath={'reassoc', 'nsz'})(add_assoc)(0, np.inf)) # 0.0
print(njit(fastmath={'reassoc'})       (add_assoc)(0, np.inf)) # nan
print(njit(fastmath={'nsz'})           (add_assoc)(0, np.inf)) # nan

Parallel=True

如果代码包含可并行化的操作(并且支持),Numba 可以编译一个将在多个本机线程上并行运行的版本(无 GIL!)。这种并行化是自动执行的,只需添加 parallel 关键字参数即可启用:

@njit(parallel=True)
def ident_parallel(x):
    return np.cos(x) ** 2 + np.sin(x) ** 2

执行时间如下:

函数名称

执行时间

ident_parallel

112 毫秒

使用 parallel=True 时,此函数的执行速度大约是 NumPy 等效函数的 5 倍,是标准 @njit 的 6 倍。

Numba 并行执行还支持显式并行循环声明,类似于 OpenMP 中的声明。要指示一个循环应并行执行,应使用 numba.prange 函数,该函数的行为类似于 Python 的 range,如果未设置 parallel=True,它仅作为 range 的别名。使用 prange 引发的循环可用于令人尴尬的并行计算和归约。

回顾reduce over sum的例子,假设求和可以无序累加,那么可以通过使用``prange``来并行化``n``中的循环。此外,在这种情况下可以添加``fastmath=True``关键字参数而不用担心,因为通过使用``parallel=True``已经假设了无序执行是有效的(因为每个线程计算部分和)。:

@njit(parallel=True)
def do_sum_parallel(A):
    # each thread can accumulate its own partial sum, and then a cross
    # thread reduction is performed to obtain the result to return
    n = len(A)
    acc = 0.
    for i in prange(n):
        acc += np.sqrt(A[i])
    return acc

@njit(parallel=True, fastmath=True)
def do_sum_parallel_fast(A):
    n = len(A)
    acc = 0.
    for i in prange(n):
        acc += np.sqrt(A[i])
    return acc

执行时间如下,fastmath 再次提升了性能。

函数名称

执行时间

do_sum_parallel

9.81 毫秒

do_sum_parallel_fast

5.37 毫秒

Intel SVML

Intel 提供了一个短向量数学库 (SVML),其中包含大量优化的超越函数,可作为编译器内部函数使用。如果环境中存在 intel-cmplr-lib-rt 包(或者 SVML 库可以简单地被定位!),那么 Numba 会自动配置 LLVM 后端,以尽可能使用 SVML 内部函数。SVML 为每个内部函数提供了高精度和低精度版本,使用哪个版本是通过 fastmath 关键字来确定的。默认情况下使用高精度版本,其精度在 1 ULP 以内,但如果 fastmath 设置为 True,则使用低精度版本的内部函数(答案在 4 ULP 以内)。

首先获取 SVML,例如使用 conda:

conda install intel-cmplr-lib-rt

备注

SVML 库之前通过 icc_rt conda 包提供。icc_rt 包后来变成了一个元包,并且在版本 2021.1.1 中,它将 intel-cmplr-lib-rt 作为依赖项之一。直接安装推荐的 intel-cmplr-lib-rt 包会导致安装的包数量减少。

使用不同的 @njit 选项组合和有无 SVML 重新运行上述的恒等函数示例 ident_np,得到以下性能结果(输入大小为 np.arange(1.e8))。作为参考,仅使用 NumPy 时,函数执行时间为 5.84s

@njit 关键字参数

SVML

执行时间

None

5.95秒

None

2.26秒

fastmath=True

5.97秒

fastmath=True

1.8秒

parallel=True

1.36秒

parallel=True

0.624秒

parallel=True, fastmath=True

1.32秒

parallel=True, fastmath=True

0.576秒

显然,SVML 显著提高了此函数的性能。在没有 SVML 的情况下,fastmath 的影响为零,这是预期的,因为原始函数中没有任何内容会从放宽数值严格性中受益。

线性代数

Numba 在 no Python 模式下支持大部分 numpy.linalg。内部实现依赖于 LAPACK 和 BLAS 库来进行数值计算,并通过 SciPy 获取必要函数的绑定。因此,为了在使用 Numba 时在 numpy.linalg 函数中获得良好的性能,必须使用针对优化良好的 LAPACK/BLAS 库构建的 SciPy。在 Anaconda 发行版的情况下,SciPy 是针对 Intel 的 MKL 构建的,该库高度优化,因此 Numba 利用了这种性能。