【问题标题】:Making four nested for loops even faster with Numba使用 Numba 使四个嵌套的 for 循环更快
【发布时间】:2018-05-26 01:23:54
【问题描述】:

我对使用 Numba 有点陌生,但我明白了它的要点。我想知道是否有任何更高级的技巧可以使四个嵌套的for 循环比我现在拥有的更快。特别是,我需要计算以下积分:

其中 B 是一个二维数组,S0 和 E 是某些参数。我的代码如下:

import numpy as np
from numba import njit, double

def calc_gb_gauss_2d(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    for i in range(n):
        for j in range(m):
            for ii in range(n):
                for jj in range(m):
                    gb[i,j]+=np.exp(-(((i-ii)*dx)**2+((j-jj)*dx)**2)/(2.0*(s0*(1.0+e*b[i,j]))**2))
            gb[i,j]*=norm
    return gb

calc_gb_gauss_2d_nb = njit(double[:, :](double[:, :],double,double,double))(calc_gb_gauss_2d)

对于大小为256x256的输入数组,计算速度为:

In [4]: a=random.random((256,256))

In [5]: %timeit calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)
The slowest run took 8.46 times longer than the fastest. This could mean that an intermediate result is being cached.
1 loop, best of 3: 1min 1s per loop

纯 Python 和 Numba 计算速度的比较给我这张图:

有什么方法可以优化我的代码以获得更好的性能?

【问题讨论】:

  • j 循环中计算(2.0*(s0*(1.0+e*b[i,j]))**2),而不是最里面的循环。
  • 另外,您的问题更适合code review,因为您的代码有效并且您正在寻找改进的方法。
  • 非常感谢..所以我应该从这里删除这个问题并将其移至代码审查吗?
  • 我想说,看看 CodeReview 上有多少关于 numba 的问题。我认为你在这里有更好的机会......
  • 1) 不要使用显式类型声明。 (您不能明确声明输入数组在内存中是连续的,这是 SIMD 向量化所必需的)。看看numba.pydata.org/numba-doc/dev/user/performance-tips.html(fastmath=True 关键字和使用英特尔 SVML 可以在性能上产生相当大的差异)。同样使用最新的 Numba 版本,最近对并行函数的性能进行了一些优化。

标签: python loops iteration jit numba


【解决方案1】:

通过使用 numpy 和一些数学,可以加速您的代码,因此它比当前的 numba 版本快一个数量级。我们还将看到,在改进后的功能上使用 numba 会使其更快。

numba 经常被过度使用——通常可以编写非常有效的纯 numpy 代码——这也是这里的情况。

手头的 numpy 代码存在一个问题:不应访问单个元素,而应利用 numpy 的内置函数——它们在大多数情况下都尽可能快。只有在无法使用这些 numpy 函数时,才会使用 numba 或 cython。

然而,这里最大的问题是问题的表述。对于固定的ij,我们有以下公式来计算(我简化了一点):

 g[i,j]=sum_ii sum_jj exp(value_ii+value_jj)
       =sum_ii sum_jj exp(value_ii)*exp(value_jj)
       =sum_ii exp(value_ii) * sum_jj exp(value_jj)

要评估最后一个公式,我们需要 O(n+m) 操作,但对于第一个,朴素的公式 O(n*m) - 完全不同!

利用 numpy 功能的第一个版本可能类似于:

def calc_ead(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    vI=np.arange(n)
    vJ=np.arange(m)
    for i in range(n):
        for j in range(m):
            II=(i-vI)*dx
            JJ=(j-vJ)*dx
            denom=2.0*(s0*(1.0+e*b[i,j]))**2
            expII=np.exp(-II*II/denom)
            expJJ=np.exp(-JJ*JJ/denom)
            gb[i,j]=norm*(expII.sum()*expJJ.sum())
    return gb

现在,与最初的 numba 实现相比:

>>> a=np.random.random((256,256))

>>> print(calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)
1min 6s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

现在是 numpy 函数:

>>> print(calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 calc_ead(a,0.1,1.0,0.5)
1.8 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

有两个观察结果:

  1. 结果是一样的。
  2. numpy 版本快 37 倍,对于更大的问题,这种差异会变得更大。

显然,您可以利用 numba 来实现更大的加速。但是,在可能的情况下使用 numpy 功能仍然是一个好主意 - 令人惊讶的是,最简单的事情可以多么微妙 - 例如甚至 calculating a sum:

>>> nb_calc_ead = njit(double[:, :](double[:, :],double,double,double))(calc_ead)
>>>print(nb_calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>>%timeit -n1 -r1 nb_calc_ead(a,0.1,1.0,0.5)
587 ms ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

还有一个因素 3!

这个问题可以并行化,但要做到这一点并非易事。我的廉价尝试使用explicit loop parallelization

from numba import njit, prange
import math

@njit(parallel=True)                 #needed, so it is parallelized
def parallel_nb_calc_ead(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    vI=np.arange(n)
    vJ=np.arange(m)
    for i in prange(n):             #outer loop = explicit prange-loop
        for j in range(m):
            denom=2.0*(s0*(1.0+e*b[i,j]))**2
            expII=np.zeros((n,))
            expJJ=np.zeros((m,))
            for k in range(n):
                II=(i-vI[k])*dx
                expII[k]=math.exp(-II*II/denom)

            for k in range(m):
                JJ=(j-vJ[k])*dx
                expJJ[k]=math.exp(-JJ*JJ/denom)
            gb[i,j]=norm*(expII.sum()*expJJ.sum())
    return gb

现在:

>>> print(parallel_nb_calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 parallel_nb_calc_ead(a,0.1,1.0,0.5)
349 ms ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

几乎意味着另一个因素 2(我的机器只有两个 CPU,取决于硬件,加速可能更大)。顺便说一句,我们的速度比原始版本快了近 200 倍。

我打赌可以改进上面的代码,但我不会去那里。


列出与calc_ead 比较的当前版本:

import numpy as np
from numba import njit, double

def calc_gb_gauss_2d(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    for i in range(n):
        for j in range(m):
            for ii in range(n):
                for jj in range(m):
                    gb[i,j]+=np.exp(-(((i-ii)*dx)**2+((j-jj)*dx)**2)/(2.0*(s0*(1.0+e*b[i,j]))**2))
            gb[i,j]*=norm
    return gb

calc_gb_gauss_2d_nb = njit(double[:, :](double[:, :],double,double,double))(calc_gb_gauss_2d)

【讨论】:

  • 这很有帮助,谢谢。特别有见地的是您对如何减少到 O(n+m) 操作的评论,我不知道这种考虑。正如 cmets 中有人建议的那样,我已将此问题转移到代码审查中。我想知道我们如何将您的答案转移到那里(我想删除这篇文章以免有两个关于同一主题的帖子)-codereview.stackexchange.com/q/194023/105140
  • @Ohm 我仍然认为这个问题对 SO 来说是可以的:它非常具体,只是因为你做了一些工作并不意味着你应该在代码审查中发布它。让我们面对现实吧,这里回答 numba 问题的用户在代码审查中并不是很活跃。
  • 您在哪个 Numba 版本和处理器上测试了您的代码?在我的机器上(Core i7 4771,Numba 0.39 dev.)。您的并行版本未通过原始实现的 np.allclose() 测试,但您的单线程版本通过了所有测试(某些值的偏差高达 4%)。我观察到的相对速度也有很大偏差(原始版本需要 205 秒,您的单线程版本需要 250 毫秒,并行版本需要 60 毫秒)
  • @max9111 感谢您的调查。我将有一段时间无法访问机器,并且不知道 numba 版本。但是,一旦我再次拿到它,我会立即调查它。令人惊讶的是,并行函数会产生不同的结果,因为据我所知,没有竞争条件
  • 我看了一点。如果删除了 prange 和 parallel=True,并行化版本也无法通过测试。您的实现的两个实现看起来非常相似,但会给出其他结果。也许是一些编译器问题......
猜你喜欢
  • 2020-06-07
  • 1970-01-01
  • 2019-04-15
  • 2014-08-20
  • 2022-01-26
  • 2021-12-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多