【问题标题】:Vectorization in a loop slower than a nested loop in numba jitted function循环中的向量化比 numba jited 函数中的嵌套循环慢
【发布时间】:2020-01-14 23:08:16
【问题描述】:

所以我正在尝试在numba(我目前使用numba 0.45.1)中结合@njit 支持的矢量化和for循环来提高性能。令人失望的是,我发现它实际上比我的代码中的纯嵌套循环实现要慢。

这是我的代码:

import numpy as np
from numba import njit

@njit
def func3(arr_in, win_arr):
    n = arr_in.shape[0]
    win_len = len(win_arr)

    result = np.full((n, win_len), np.nan)

    alpha_arr = 2 / (win_arr + 1)

    e = np.full(win_len, arr_in[0])
    w = np.ones(win_len)

    two_index = np.nonzero(win_arr <= 2)[0][-1]+1
    result[0, :two_index] = arr_in[0]

    for i in range(1, n):
        w = w + (1-alpha_arr)**i
        e = e*(1-alpha_arr) + arr_in[i]
        result[i,:] = e /w

    return result

@njit
def func4(arr_in, win_arr):
    n = arr_in.shape[0]
    win_len = len(win_arr)

    result = np.full((n, win_len), np.nan)

    alpha_arr = 2 / (win_arr + 1)

    e = np.full(win_len, arr_in[0])
    w = np.ones(win_len)

    two_index = np.nonzero(win_arr <= 2)[0][-1]+1
    result[0, :two_index] = arr_in[0]

    for i in range(1, n):
        for col in range(len(win_arr)):
            w[col] = w[col] + (1-alpha_arr[col])**i
            e[col] = e[col]*(1-alpha_arr[col]) + arr_in[i]
            result[i,col] = e[col] /w[col]

    return result

if __name__ == '__main__':
    np.random.seed(0)
    data_size = 200000
    winarr_size = 1000

    data = np.random.uniform(0,1000, size = data_size)+29000
    win_array = np.arange(1, winarr_size+1)

    abc_test3= func3(data, win_array)
    abc_test4= func4(data, win_array)

    print(np.allclose(abc_test3, abc_test4, equal_nan = True))

我使用以下配置对这两个函数进行了基准测试:

(data_size,winarr_size) = (200000,100), (200000,200),(200000,1000), (200000,2000), (20000,10000), (2000,100000).

并发现纯嵌套 for-loop 实现 (func4) 始终比 for-loop 与矢量化混合实现 (func3) 更快(大约快 2-5%)。


我的问题如下:

1) 进一步提高代码速度需要做哪些改动?

2)为什么函数的向量化版本的计算时间随着win_arr的大小线性增长?我认为矢量化应该使得无论矢量有多大/多小,运算速度都是恒定的,但显然这在这种情况下并不成立。

3) 是否存在一般情况下矢量化运算的计算时间仍会随输入大小线性增长?

【问题讨论】:

  • 经典numpy意义上的“向量化”是使用编译后的numpy全数组方法。它仍然使用循环,它们只是在编译代码中执行,而不是以 Python 速度执行。 numba 会为您进行编译,并且在使用循环时它实际上可能会创建更紧凑的 C 代码。速度差异约为 5%,您依赖于 numba 实现细节。
  • “矢量化”命令通常较慢,因为它们都必须被(多个)for 循环替换。但是func3还有很多需要改进的地方。例如。禁用检查除以零,不必要的幂运算,可以用乘法代替。 stackoverflow.com/a/57062221/4045774
  • Numba 通常最适合显式循环(NumPy 函数只能被调用,不能被优化)。请参阅示例 hereherehere。此外,您在代码中调用了两次func4。您可以尝试使用njitparallel=True 选项和prange 来加快速度,尽管它并不总是能让速度更快。
  • 是的,如果您愿意,我可以添加答案。但在您的情况下,这并不容易,因为下溢对性能有很大影响。
  • 我不确定,但这似乎是对性能不佳的解释en.wikipedia.org/wiki/Denormal_number

标签: python performance numpy vectorization numba


【解决方案1】:

对于复杂的数学运算,例如(pow、除法、...),请务必三思而后行。如果您可以用简单的运算(例如乘法、加法和减法)来替换它们,那总是值得一试的。

请注意,将 alpha 与自身重复相乘仅在代数上与直接用幂计算相同。由于这是数值数学,因此结果可能会有所不同。

还要避免不必要的临时数组。

第一次尝试

@nb.njit(error_model="numpy",parallel=True)
def func5(arr_in, win_arr):
    #filling the whole array with NaNs isn't necessary
    result = np.empty((win_arr.shape[0],arr_in.shape[0]))
    for col in range(win_arr.shape[0]):
        result[col,0]=np.nan

    two_index = np.nonzero(win_arr <= 2)[0][-1]+1
    result[:two_index,0] = arr_in[0]

    for col in nb.prange(win_arr.shape[0]):
        alpha=1.-(2./ (win_arr[col] + 1.))
        alpha_exp=alpha

        w=1.
        e=arr_in[0]

        for i in range(1, arr_in.shape[0]):
            w+= alpha_exp
            e = e*alpha + arr_in[i]
            result[col,i] = e/w
            alpha_exp*=alpha

    return result.T

第二次尝试(避免下溢)

@nb.njit(error_model="numpy",parallel=True)
def func7(arr_in, win_arr):
    #filling the whole array with NaNs isn't necessary
    result = np.empty((win_arr.shape[0],arr_in.shape[0]))
    for col in range(win_arr.shape[0]):
        result[col,0]=np.nan

    two_index = np.nonzero(win_arr <= 2)[0][-1]+1
    result[:two_index,0] = arr_in[0]

    for col in nb.prange(win_arr.shape[0]):
        alpha=1.-(2./ (win_arr[col] + 1.))
        alpha_exp=alpha

        w=1.
        e=arr_in[0]

        for i in range(1, arr_in.shape[0]):
            w+= alpha_exp
            e = e*alpha + arr_in[i]
            result[col,i] = e/w

          if np.abs(alpha_exp)>=1e-308:
              alpha_exp*=alpha
          else:
              alpha_exp=0.

    return result.T

时间

%timeit abc_test3= func3(data, win_array)
7.17 s ± 45.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit abc_test4= func4(data, win_array)
7.13 s ± 13.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
#from MSeifert answer (parallelized)
%timeit abc_test6= func6(data, win_array)
3.42 s ± 153 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit abc_test5= func5(data, win_array)
1.22 s ± 22.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit abc_test7= func7(data, win_array)
238 ms ± 5.55 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

【讨论】:

  • 我仍然对快 20 倍的速度感到惊讶。实际上,我花了一些时间运行您的函数来亲自查看性能,并被震撼了。看起来我的代码库中有几个地方可以检查是否有任何下溢处理开销。
  • 虽然在数学中重复乘法与浮点数的乘方是相同的,但你会得到不同的结果(如果指数很大,则显着不同)。这与 fastmath 相同。如果您不太关心精度并且只执行一次操作,那很好,但是如果您对相同的数字进行重复计算,那么效果会累积到结果可能过于不精确的地步。这就是为什么我没有将它们(即使它们明显更快)添加到我的答案中。您是否将结果与原始结果进行了比较?
  • 我有点担心np.abs(alpha_exp)&gt;=1e-100 避免下溢的方法。虽然它避免了下溢,但它完全损害了结果。而不是该检查,可能需要添加检查操作后的值是否为0,然后对问题进行错误/警告或在之后使用最小的双精度值(比生成 nans 更好,因为除以零)。
  • @mathguy Numpy 数组默认是 C 排序的(最后一个维度变化最快)。内存访问模式应根据内存布局,这对性能很重要。最后的操作不是真正的转置,它只是改变了你看待内存的方式(检查 ndarray.flags)。
  • @mathguy 是的,可能会有开销。我更改了数组布局,因为在迭代方法中,我必须在内部循环中迭代 i。
【解决方案2】:

您似乎误解了“矢量化”的含义。向量化意味着您编写的代码对数组进行操作,就好像它们是标量一样——但这就是代码的样子,与性能无关。

在 Python/NumPy 世界中,向量化还意味着与循环代码相比,向量化操作中的循环开销(通常)要小得多。但是矢量化代码仍然需要执行循环(即使它隐藏在库中)!

此外,如果您使用 numba 编写循环,numba 将编译它并创建执行速度(通常)与矢量化 NumPy 代码一样快的代码。这意味着在 numba 函数内部,矢量化代码和非矢量化代码之间没有显着的性能差异。

所以这应该回答你的问题:

2)为什么函数的向量化版本的计算时间随着win_arr的大小线性增长?我认为矢量化应该使得无论矢量有多大/多小,操作速度都是恒定的,但显然这在这种情况下并不成立。

它线性增长,因为它仍然需要迭代。在矢量化代码中,循环只是隐藏在库例程中。

3) 是否存在一般情况下矢量化运算的计算时间仍会随输入大小线性增长?

没有。


您还询问了可以做些什么来加快速度。

cmets 已经提到你可以并行化它:

import numpy as np
import numba as nb

@nb.njit(parallel=True)
def func6(arr_in, win_arr):
    n = arr_in.shape[0]
    win_len = len(win_arr)

    result = np.full((n, win_len), np.nan)

    alpha_arr = 2 / (win_arr + 1)

    e = np.full(win_len, arr_in[0])
    w = np.ones(win_len)

    two_index = np.nonzero(win_arr <= 2)[0][-1]+1
    result[0, :two_index] = arr_in[0]

    for i in range(1, n):
        for col in nb.prange(len(win_arr)):
            w[col] = w[col] + (1-alpha_arr[col])**i
            e[col] = e[col] * (1-alpha_arr[col]) + arr_in[i]
            result[i,col] = e[col] /w[col]

    return result

这使我的机器上的代码速度更快(4 核)。

但是还有一个问题是您的算法可能在数值上不稳定。当你将(1-alpha_arr[col])**i 提升到十万次方时,它会在某个时候下溢:

>>> alpha = 0.01
>>> for i in [1, 10, 100, 1_000, 10_000, 50_000, 100_000, 200_000]:
...     print((1-alpha)**i)
0.99
0.9043820750088044
0.3660323412732292
4.317124741065786e-05
2.2487748498162805e-44
5.750821364590612e-219
0.0  # <-- underflow
0.0

【讨论】:

  • 有趣。现在已经证明,在性能方面,njited 循环比使用 CPU 作为处理器的 numpy 矢量化更快,我想知道使用 GPU 是否同样适用?此外,在 tensorflow 中完成的向量化操作是否也比 numba 中的 njited 嵌套循环慢?
  • @mathguy 我目前在没有 GPU 和 tensorflow 的计算机上,所以我不知道也无法对其进行基准测试。
猜你喜欢
  • 2016-12-09
  • 1970-01-01
  • 2013-03-03
  • 2021-07-29
  • 2020-04-03
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多