创建 NumPy 通用函数

有两种类型的通用函数:

  • 那些对标量进行操作的函数,这些是“通用函数”或 *ufuncs*(见下文 @vectorize)。

  • 那些对高维数组和标量进行操作的函数,这些是“广义通用函数”或 *gufuncs*(如下 @guvectorize)。

@vectorize 装饰器

Numba 的 vectorize 允许接受标量输入参数的 Python 函数作为 NumPy ufuncs 使用。创建传统的 NumPy ufunc 不是一个最直接的过程,并且涉及编写一些 C 代码。Numba 使这变得简单。使用 vectorize() 装饰器,Numba 可以将纯 Python 函数编译成一个 ufunc,该 ufunc 在 NumPy 数组上的操作速度与用 C 编写的传统 ufuncs 一样快。

使用 vectorize() ,你可以将函数编写为对输入标量进行操作,而不是数组。Numba 将生成周围的循环(或 内核 ),允许对实际输入进行高效迭代。

The vectorize() 装饰器有两种操作模式:

  • 急切编译,或装饰时编译:如果你向装饰器传递一个或多个类型签名,你将构建一个 NumPy 通用函数(ufunc)。本小节其余部分描述了使用装饰时编译构建 ufuncs。

  • 惰性或调用时编译:当不提供任何签名时,装饰器将为您提供一个 Numba 动态通用函数(DUFunc),该函数在调用时动态编译一个新的内核,当输入类型之前不受支持时。后面的一个小节“动态通用函数”更深入地描述了这种模式。

如上所述,如果您将一组签名传递给 vectorize() 装饰器,您的函数将被编译成一个 NumPy ufunc。在基本情况下,只会传递一个签名:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_one_signature
1from numba import vectorize, float64
2
3@vectorize([float64(float64, float64)])
4def f(x, y):
5    return x + y

如果你传递多个签名,请注意你必须先传递最具体的签名,然后再传递最不具体的签名(例如,单精度浮点数在双精度浮点数之前),否则基于类型的调度将无法按预期工作:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_multiple_signatures
1from numba import vectorize, int32, int64, float32, float64
2import numpy as np
3
4@vectorize([int32(int32, int32),
5            int64(int64, int64),
6            float32(float32, float32),
7            float64(float64, float64)])
8def f(x, y):
9    return x + y

该函数将在指定的数组类型上按预期工作:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_multiple_signatures
1a = np.arange(6)
2result = f(a, a)
3# result == array([ 0,  2,  4,  6,  8, 10])
来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_multiple_signatures
1a = np.linspace(0, 1, 6)
2result = f(a, a)
3# Now, result == array([0. , 0.4, 0.8, 1.2, 1.6, 2. ])

但在其他类型上工作时会失败:

>>> a = np.linspace(0, 1+1j, 6)
>>> f(a, a)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: ufunc 'ufunc' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''

你可能会问自己,“为什么我要这样做,而不是使用 @jit 装饰器编译一个简单的迭代循环?”。答案是,NumPy ufuncs 自动获得其他功能,如归约、累积或广播。使用上面的例子:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_multiple_signatures
 1a = np.arange(12).reshape(3, 4)
 2# a == array([[ 0,  1,  2,  3],
 3#             [ 4,  5,  6,  7],
 4#             [ 8,  9, 10, 11]])
 5
 6result1 = f.reduce(a, axis=0)
 7# result1 == array([12, 15, 18, 21])
 8
 9result2 = f.reduce(a, axis=1)
10# result2 == array([ 6, 22, 38])
11
12result3 = f.accumulate(a)
13# result3 == array([[ 0,  1,  2,  3],
14#                   [ 4,  6,  8, 10],
15#                   [12, 15, 18, 21]])
16
17result4 = f.accumulate(a, axis=1)
18# result3 == array([[ 0,  1,  3,  6],
19#                   [ 4,  9, 15, 22],
20#                   [ 8, 17, 27, 38]])

参见

ufuncs 的标准功能 (NumPy 文档)。

备注

在编译代码中仅支持 ufuncs 的广播和归约功能。

The vectorize() 装饰器支持多个 ufunc 目标:

目标

描述

cpu

单线程 CPU

并行

多核CPU

cuda

CUDA GPU

备注

这将创建一个 类似ufunc 的对象。详情请参阅 CUDA ufunc 的文档

一般的指导原则是根据数据大小和算法选择不同的目标。”cpu” 目标适用于小数据量(大约小于1KB)和低计算强度的算法。它的开销最小。”parallel” 目标适用于中等数据量(大约小于1MB)。线程化会增加少量延迟。”cuda” 目标适用于大数据量(大约大于1MB)和高计算强度的算法。在GPU和内存之间传输数据会增加显著的开销。

从 Numba 0.59 开始,cpu 目标在编译代码中支持以下属性和方法:

  • ufunc.nin

  • ufunc.nout

  • ufunc.nargs

  • ufunc.identity

  • ufunc.signature

  • ufunc.reduce() (仅前5个参数 - 实验性功能)

@guvectorize 装饰器

虽然 vectorize() 允许你编写一次处理一个元素的通用函数,但 guvectorize() 装饰器将这一概念更进一步,允许你编写可以处理输入数组中任意数量元素的通用函数,并接受和返回维度不同的数组。典型的例子是运行中值或卷积滤波器。

vectorize() 函数相反,guvectorize() 函数不返回其结果值:它们将其作为数组参数,该数组必须由函数填充。这是因为数组实际上是由 NumPy 的调度机制分配的,该机制调用了 Numba 生成的代码。

类似于 vectorize() 装饰器,guvectorize() 也有两种操作模式:急切模式(装饰时编译)和懒惰模式(调用时编译)。

这里是一个非常简单的例子:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize
1from numba import guvectorize, int64
2import numpy as np
3
4@guvectorize([(int64[:], int64, int64[:])], '(n),()->(n)')
5def g(x, y, res):
6    for i in range(x.shape[0]):
7        res[i] = x[i] + y

底层Python函数只是将给定的标量(y)添加到一维数组的所有元素中。更有趣的是声明。那里有两件事:

  • 输入和输出 布局 的声明,以符号形式表示:(n),()->(n) 告诉 NumPy 该函数接受一个 n 元素的一维数组,一个标量(符号上用空元组 () 表示),并返回一个 n 元素的一维数组;

  • 根据 @vectorize 支持的具体 签名 列表;这里,与上面的例子一样,我们演示了 int64 数组。

备注

一维数组类型也可以接收标量参数(那些形状为 () 的参数)。在上面的例子中,第二个参数也可以声明为 int64[:]。在这种情况下,值必须通过 y[0] 读取。

我们现在可以通过一个简单的例子来检查编译后的ufunc的作用:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize
1a = np.arange(5)
2result = g(a, 2)
3# result == array([2, 3, 4, 5, 6])

好处是 NumPy 会根据输入的形状自动处理更复杂的输入:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize
 1a = np.arange(6).reshape(2, 3)
 2# a == array([[0, 1, 2],
 3#             [3, 4, 5]])
 4
 5result1 = g(a, 10)
 6# result1 == array([[10, 11, 12],
 7#                   [13, 14, 15]])
 8
 9result2 = g(a, np.array([10, 20]))
10g(a, np.array([10, 20]))
11# result2 == array([[10, 11, 12],
12#                   [23, 24, 25]])

备注

:func:`~numba.vectorize:func:`~numba.guvectorize 都支持传递 nopython=True 类似于 @jit 装饰器中的用法。使用它来确保生成的代码不会回退到 对象模式

标量返回值

现在假设我们想从 guvectorize() 返回一个标量值。为此,我们需要:

  • 在签名中,使用 [:] 声明标量返回值,类似于一维数组(例如 int64[:])。

  • 在布局中,将其声明为 ()

  • 在实现中,写入第一个元素(例如 res[0] = acc)。

以下示例函数计算一维数组 (x) 与标量 (y) 的和,并将其作为标量返回:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_scalar_return
1from numba import guvectorize, int64
2import numpy as np
3
4@guvectorize([(int64[:], int64, int64[:])], '(n),()->()')
5def g(x, y, res):
6    acc = 0
7    for i in range(x.shape[0]):
8        acc += x[i] + y
9    res[0] = acc

现在,如果我们对数组应用这个包装函数,我们会得到一个标量值作为输出:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_scalar_return
1a = np.arange(5)
2result = g(a, 2)
3# At this point, result == 20.

覆盖输入值

在大多数情况下,写入输入也可能看似有效 - 然而,这种行为不能依赖。考虑以下示例函数:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_overwrite
1from numba import guvectorize, float64
2import numpy as np
3
4@guvectorize([(float64[:], float64[:])], '()->()')
5def init_values(invals, outvals):
6    invals[0] = 6.5
7    outvals[0] = 4.2

使用 float64 类型的数组调用 init_values 函数会导致输入发生可见变化:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_overwrite
1invals = np.zeros(shape=(3, 3), dtype=np.float64)
2# invals == array([[6.5, 6.5, 6.5],
3#                  [6.5, 6.5, 6.5],
4#                  [6.5, 6.5, 6.5]])
5
6outvals = init_values(invals)
7# outvals == array([[4.2, 4.2, 4.2],
8#                   [4.2, 4.2, 4.2],
9#                   [4.2, 4.2, 4.2]])

这是可行的,因为 NumPy 可以直接将输入数据传递给 init_values 函数,因为数据的 dtype 与声明的参数匹配。然而,它也可能创建并传递一个临时数组,在这种情况下,对输入的更改将会丢失。例如,当需要类型转换时,这种情况就可能发生。为了演示,我们可以使用一个 float32 类型的数组来调用 init_values 函数:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_overwrite
 1invals = np.zeros(shape=(3, 3), dtype=np.float32)
 2# invals == array([[0., 0., 0.],
 3#                  [0., 0., 0.],
 4#                  [0., 0., 0.]], dtype=float32)
 5outvals = init_values(invals)
 6# outvals == array([[4.2, 4.2, 4.2],
 7#                   [4.2, 4.2, 4.2],
 8#                   [4.2, 4.2, 4.2]])
 9print(invals)
10# invals == array([[0., 0., 0.],
11#                  [0., 0., 0.],
12#                  [0., 0., 0.]], dtype=float32)

在这种情况下,invals 数组没有变化,因为临时转换的数组被修改了。

要解决这个问题,需要告诉 GUFunc 引擎 invals 参数是可写的。这可以通过传递 writable_args=('invals',)``(按名称指定)或 ``writable_args=(0,)``(按位置指定)给 ``@guvectorize 来实现。现在,上面的代码可以按预期工作:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_overwrite
 1@guvectorize(
 2    [(float64[:], float64[:])],
 3    '()->()',
 4    writable_args=('invals',)
 5)
 6def init_values(invals, outvals):
 7    invals[0] = 6.5
 8    outvals[0] = 4.2
 9
10invals = np.zeros(shape=(3, 3), dtype=np.float32)
11# invals == array([[0., 0., 0.],
12#                  [0., 0., 0.],
13#                  [0., 0., 0.]], dtype=float32)
14outvals = init_values(invals)
15# outvals == array([[4.2, 4.2, 4.2],
16#                   [4.2, 4.2, 4.2],
17#                   [4.2, 4.2, 4.2]])
18print(invals)
19# invals == array([[6.5, 6.5, 6.5],
20#                  [6.5, 6.5, 6.5],
21#                  [6.5, 6.5, 6.5]], dtype=float32)

动态通用函数

如上所述,如果你没有向 vectorize() 装饰器传递任何签名,你的 Python 函数将被用来构建一个动态通用函数,或 DUFunc。例如:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_dynamic
1from numba import vectorize
2
3@vectorize
4def f(x, y):
5    return x * y

生成的 f() 是一个 DUFunc 实例,它最初不支持任何输入类型。当你调用 f() 时,Numba 会在你传递先前不支持的输入类型时生成新的内核。以上述示例为例,以下解释器交互说明了动态编译的工作原理:

>>> f
<numba._DUFunc 'f'>
>>> f.ufunc
<ufunc 'f'>
>>> f.ufunc.types
[]

上面的例子表明 DUFunc 实例不是 ufuncs。 与其子类化 ufunc,DUFunc 实例通过保持一个 ufunc 成员来工作,然后将 ufunc 属性读取和方法调用委托给这个成员(也称为类型聚合)。 当我们查看 ufunc 支持的初始类型时,我们可以验证没有支持的类型。

让我们尝试调用 f():

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_dynamic
1result = f(3,4)
2# result == 12
3
4print(f.types)
5# ['ll->l']

如果这是一个普通的 NumPy ufunc,我们会看到一个异常,抱怨 ufunc 无法处理输入类型。当我们用整数参数调用 f() 时,我们不仅得到了一个答案,而且可以验证 Numba 创建了一个支持 C long 整数的循环。

我们可以通过使用不同的输入调用 f() 来添加额外的循环:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_dynamic
1result = f(1.,2.)
2# result == 2.0
3
4print(f.types)
5# ['ll->l', 'dd->d']

我们现在可以验证,Numba 为处理浮点输入添加了第二个循环,"dd->d"

如果我们混合输入类型给 f(),我们可以验证 NumPy ufunc 转换规则 仍然有效:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_dynamic
1result = f(1,2.)
2# result == 2.0
3
4print(f.types)
5# ['ll->l', 'dd->d']

这个例子展示了调用 f() 时混合了不同类型,导致 NumPy 选择了浮点数循环,并将整数参数转换为浮点数值。因此,Numba 没有创建一个特殊的 "dl->d" 内核。

这种 DUFunc 行为让我们想到了在“The @vectorize decorator”小节中给出的警告,但不同的是,这里不是装饰器中的签名声明顺序,而是调用顺序至关重要。如果我们首先传入浮点数参数,那么任何带有整数参数的调用都会被转换为双精度浮点数值。例如:

来自 numba/tests/doc_examples/test_examples.py 中的 test_vectorize_dynamic
 1@vectorize
 2def g(a, b):
 3    return a / b
 4
 5print(g(2.,3.))
 6# 0.66666666666666663
 7
 8print(g(2,3))
 9# 0.66666666666666663
10
11print(g.types)
12# ['dd->d']

如果你需要对各种类型签名提供精确的支持,你应该在 vectorize() 装饰器中指定它们,而不是依赖动态编译。

动态广义通用函数

类似于动态通用函数,如果你没有为 guvectorize() 装饰器指定任何类型,你的 Python 函数将被用来构建一个动态广义通用函数,或 GUFunc。例如:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_dynamic
1from numba import guvectorize
2import numpy as np
3
4@guvectorize('(n),()->(n)')
5def g(x, y, res):
6    for i in range(x.shape[0]):
7        res[i] = x[i] + y

我们可以验证生成的函数 g() 是一个 GUFunc 实例,它开始时没有支持的输入类型。例如:

>>> g
<numba._GUFunc 'g'>
>>> g.ufunc
<ufunc 'g'>
>>> g.ufunc.types
[]

类似于 DUFunc ,当调用 g() 时,numba 会为之前不支持的输入类型生成新的内核。以下一组解释器交互将说明 GUFunc 的动态编译是如何工作的:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_dynamic
1x = np.arange(5, dtype=np.int64)
2y = 10
3res = np.zeros_like(x)
4g(x, y, res)
5# res == array([10, 11, 12, 13, 14])
6print(g.types)
7# ['ll->l']

如果这是一个普通的 guvectorize() 函数,我们会看到一个异常,抱怨 ufunc 无法处理给定的输入类型。当我们使用输入参数调用 g() 时,numba 会为输入类型创建一个新的循环。

我们可以通过使用新参数调用 g() 来添加额外的循环:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_dynamic
1x = np.arange(5, dtype=np.double)
2y = 2.2
3res = np.zeros_like(x)
4g(x, y, res)
5# res == array([2.2, 3.2, 4.2, 5.2, 6.2])

我们现在可以验证,Numba 为处理浮点输入添加了第二个循环,"dd->d"

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_dynamic
1print(g.types) # shorthand for g.ufunc.types
2# ['ll->l', 'dd->d']

还可以验证 NumPy ufunc 的类型转换规则是否按预期工作:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_dynamic
1x = np.arange(5, dtype=np.int64)
2y = 2
3res = np.zeros_like(x)
4g(x, y, res)
5print(res)
6# res == array([2, 3, 4, 5, 6])

如果你需要对各种类型签名提供精确支持,你不应该依赖动态编译,而应该在 guvectorize() 装饰器中将类型指定为第一个参数。

@guvectorize 函数也可以从 jitted 函数中调用。例如:

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_jit
 1import numpy as np
 2
 3from numba import jit, guvectorize
 4
 5@guvectorize('(n)->(n)')
 6def copy(x, res):
 7    for i in range(x.shape[0]):
 8        res[i] = x[i]
 9
10@jit(nopython=True)
11def jit_fn(x, res):
12    copy(x, res)

警告

广播功能尚未支持。在需要广播的情况下调用 guvectorize 函数可能会导致不正确的行为。Numba 将尝试检测这些情况并引发异常。

来自 numba/tests/doc_examples/test_examples.py 中的 test_guvectorize_jit
 1import numpy as np
 2from numba import jit, guvectorize
 3
 4@guvectorize('(n)->(n)')
 5def copy(x, res):
 6    for i in range(x.shape[0]):
 7        res[i] = x[i]
 8
 9@jit(nopython=True)
10def jit_fn(x, res):
11    copy(x, res)
12
13x = np.ones((1, 5))
14res = np.empty((5,))
15with self.assertRaises(ValueError) as raises:
16    jit_fn(x, res)