如何在使用Numba时对Python for循环进行并行化处理

26

我使用Anaconda分发版的Python和Numba,编写了以下Python函数,该函数将一个稀疏矩阵A(以CSR格式存储)乘以一个密集向量x

@jit
def csrMult( x, Adata, Aindices, Aindptr, Ashape ):

    numRowsA = Ashape[0]
    Ax       = numpy.zeros( numRowsA )

    for i in range( numRowsA ):
        Ax_i = 0.0
        for dataIdx in range( Aindptr[i], Aindptr[i+1] ):

            j     = Aindices[dataIdx]
            Ax_i +=    Adata[dataIdx] * x[j]

        Ax[i] = Ax_i

    return Ax 

这里的A是一个大型的scipy稀疏矩阵。

>>> A.shape
( 56469, 39279 )
#                  having ~ 142,258,302 nonzero entries (so about 6.4% )
>>> type( A[0,0] )
dtype( 'float32' )

而且 x 是一个 numpy 数组。下面是调用上述函数的代码片段:

x       = numpy.random.randn( A.shape[1] )
Ax      = A.dot( x )   
AxCheck = csrMult( x, A.data, A.indices, A.indptr, A.shape )

注意使用修饰符@jit,它告诉Numba对函数csrMult()进行即时编译。
在我的实验中,csrMult()函数的运行速度大约是scipy.dot()方法的两倍。这对于Numba来说是一个非常令人印象深刻的结果。
然而,MATLAB执行这个矩阵-向量乘法的速度大约比csrMult()6倍。我认为这是因为MATLAB在执行稀疏矩阵-向量乘法时使用了多线程。

问题:

如何在使用Numba时并行化外部的for循环?
Numba以前有一个prange()函数,用于简化并行化尴尬的并行for循环。不幸的是,Numba现在没有prange()函数[ 实际上,这是错误的,请参见下面的编辑]。那么,在Numba的prange()函数消失之后,正确的并行化这个for循环的方法是什么?
当从Numba中删除prange()时,Numba的开发人员有什么替代方案?

编辑1:
我更新到了最新版本的Numba,即.35版本,prange()又回来了!它没有包含在我使用的版本.33中。
这是个好消息,但不幸的是,当我尝试使用prange()并行化我的for循环时,我收到了一个错误消息。这里是Numba文档中的一个并行for循环示例(请参见第1.9.2节“显式并行循环”),下面是我的新代码:

from numba import njit, prange
@njit( parallel=True )
def csrMult_numba( x, Adata, Aindices, Aindptr, Ashape):

    numRowsA = Ashape[0]    
    Ax       = np.zeros( numRowsA )

    for i in prange( numRowsA ):
        Ax_i = 0.0        
        for dataIdx in range( Aindptr[i],Aindptr[i+1] ):

            j     = Aindices[dataIdx]
            Ax_i +=    Adata[dataIdx] * x[j]

        Ax[i] = Ax_i            

    return Ax 

当我使用上面给定的代码片段调用此函数时,我收到以下错误:

AttributeError:在 nopython (转换为 parfors) 'SetItem' 失败 对象没有属性 'get_targets'。


鉴于上述尝试使用 prange 崩溃,我的问题如下:

什么是正确的方式(使用 prange 或其他方法)来并行化这个 Python for 循环?

如下所示,在 C++ 中并行化类似的 for 循环并获得 20-omp-threads 上的 8x 加速非常容易。因为稀疏矩阵向量乘法是科学计算中的基本操作,因此必须有一种使用 Numba 的方法来处理它。


编辑2:
这里是我对 csrMult() 的 C++ 版本。在 C++ 版本中并行化 for() 循环可以使代码的测试速度快大约 8 倍。这使我认为,在使用 Numba 时 Python 版本应该能够实现类似的加速。

void csrMult(VectorXd& Ax, VectorXd& x, vector<double>& Adata, vector<int>& Aindices, vector<int>& Aindptr)
{
    // This code assumes that the size of Ax is numRowsA.
    #pragma omp parallel num_threads(20)
    {       
        #pragma omp for schedule(dynamic,590) 
        for (int i = 0; i < Ax.size(); i++)
        {
            double Ax_i = 0.0;
            for (int dataIdx = Aindptr[i]; dataIdx < Aindptr[i + 1]; dataIdx++)
            {
                Ax_i += Adata[dataIdx] * x[Aindices[dataIdx]];
            }

            Ax[i] = Ax_i;
        }
    }
}

是的,这是一个实验性功能(取决于您的 numba 版本,可能尚不可用)。好的,如果删除了该选项,我接下来会尝试将实现移植到 @vectorize@guvectorize(以生成 ufuncs)。甚至可能需要将内部循环拆分为另一个函数。 - f0xdx
{btsdaf} - user3666197
{btsdaf} - user3666197
{btsdaf} - MSeifert
{btsdaf} - littleO
显示剩余4条评论
2个回答

27
Numba已经更新了,现在prange()可以使用了!(我回答了自己的问题。) 关于Numba并行计算能力的改进在这篇博客文章中讨论,日期为2017年12月12日。以下是博客中的相关片段:

很久以前(超过20个版本之前),Numba曾经支持一种用于编写并行for循环的习惯用法,称为prange()。在2014年对代码库进行了重大重构之后,这个功能不得不被移除,但自那时以来,它一直是最常被要求的Numba功能之一。在Intel开发人员并行化数组表达式之后,他们意识到重新引入prange将会相当容易

使用Numba版本0.36.1,我可以使用以下简单的代码并行化我的尴尬并行for循环:
@numba.jit(nopython=True, parallel=True)
def csrMult_parallel(x,Adata,Aindices,Aindptr,Ashape): 
    
    numRowsA = Ashape[0]    
    Ax = np.zeros(numRowsA)
    
    for i in numba.prange(numRowsA):
        Ax_i = 0.0        
        for dataIdx in range(Aindptr[i],Aindptr[i+1]):
            
            j = Aindices[dataIdx]
            Ax_i += Adata[dataIdx]*x[j]
            
        Ax[i] = Ax_i            
                        
    return Ax

在我的实验中,将for循环并行化使得函数的执行速度比我在问题开始时发布的版本快了大约8倍,而那个版本已经使用了Numba,但没有并行化。此外,在我的实验中,并行化版本的速度大约比使用scipy的稀疏矩阵-向量乘法函数的命令Ax = A.dot(x)快5倍。Numba已经击败了scipy,我终于有了一个与MATLAB一样快的Python稀疏矩阵-向量乘法例程。

3
一个很酷的消息。如果这在Intel、AMD、ARM等架构上普遍适用,那么代码重新设计确实是一个聪明的举动。如果诀窍只是利用了基于硬件的扩展寄存器和向量化操作指令带来的新可能性,而其他处理器架构上没有这些指令,那么ARM和可能也不会享受到您所观察到的性能提升。无论如何,享受新的力量,以进一步扩展您宝贵的研究。 - user3666197
1
谢谢您指引我这个!我已经将链接转发给Numba团队以鼓励他们。 - Michael Grant
2
@MichaelGrant 我有一个问题想问你,如果可以的话。您知道Numba是否提供了一种在使用prange()并行化for循环时指定“块大小”的方法吗? - littleO
2
仔细思考后,A * x 在 MATLAB 中比 A' * x 慢是有道理的。使用 CSC 存储时,A' * x 更容易并行化,因为每一行都有自己的线程。 - Michael Grant
1
@GeoffreyNegiar 我之前在接受自己的答案和取消其他人回答的接受状态方面有些犹豫,但你是正确的。我已经把这个作为被接受的答案了。 - littleO
显示剩余9条评论

8
感谢您的量化更新,丹尼尔。
以下内容可能难以接受,但请相信我,有更多需要考虑的因素。我曾经处理过矩阵规模为N [TB]; N > 10及其稀疏伴侣的 / / problems,所以这些经验可能对您的进一步观点有用。

警告:不要期望免费提供晚餐

希望并行化代码听起来像是一个越来越常见的现代化重申的神力。问题不在于代码,而在于这种移动的成本。

经济是最大的问题。Amdahl定律最初由Gene Amdahl制定,未考虑到每个实现中都必须支付的[PAR]-进程设置成本+[PAR]-进程结束和终止成本。过度严格的Amdahl定律描述了这些不可避免的负面影响的规模,并帮助理解在选择引入并行化之前必须评估的一些新方面(以可接受的成本为代价,因为很容易付出比所获得的更多的代价 - 在那里,对降低处理性能的幼稚失望只是故事的一部分)。

如果您想更好地理解这个主题并预先计算实际的"最小"-子问题-"大小",以便从现实世界工具中介绍将子问题并行分割到N_trully_[PAR]_processes(不是任何"just"-[CONCURRENT],而是真正的[PARALLEL] - 这些方式并不相等),请随意阅读有关超额严格阿姆达尔定律重新制定的更多帖子,以使[PAR]总开销至少得到合理化。


Python可能会得到类固醇来提高性能:

Python是一个伟大的原型生态系统,而numbanumpy和其他编译扩展可以帮助大大提高性能,远远超过本地的GIL步进式Python(co-)处理通常提供的性能。

在这里,您尝试强制执行numba.jit(),几乎免费地安排工作,只需通过其自动化的jit()时间词法分析器(将代码放在其中),它应该既“理解”您的全局目标(要做什么),也提出一些矢量化技巧(如何最佳地组装一堆CPU指令以实现最大的代码执行效率)。

这听起来很容易,但事实并非如此。

Travis Oliphant的团队在numba工具方面取得了巨大进展,但让我们保持现实和公正,不要期望在尝试转换代码并组装更有效的机器指令流以实现高级任务目标时,在.jit()词法分析器+代码分析中实现任何形式的自动化魔法。
@guvectorize?这里?认真吗?
由于[PSPACE]的大小,您可能会立即忘记要求numba以某种方式有效地“填充”GPU引擎与数据,其内存占用远远落后于GPU-GDDR大小(根本不谈太“浅”的GPU内核大小,用于如此数学上“微小”的处理,只是潜在地乘以[PAR],但稍后在[SEQ]中求和)。
(重新)加载GPU数据需要大量时间。即使支付了这个代价,In-GPU内存延迟对于“小型”GPU内核经济也不是很友好--您的GPU-SMX代码执行将不得不支付 ~ 350-700 [ns] 的费用才能获取一个数字(最有可能没有自动重新对齐以便在下一步中进行最佳协同SM缓存友好的重复使用,您可能会注意到,您从未,让我重申,从未重复使用单个矩阵单元,因此在那些350~700 [ns]每个矩阵单元下,缓存本身并不会提供任何东西),而聪明纯粹的numpy向量化代码可以在最大的[PSPACE]足迹上每个单元处理矩阵向量乘积少于1 [ns]。

这是一个比较的标准。

(分析更好地显示了硬性事实,但原则事先是众所周知的,没有测试如何将几TB的数据移动到GPU结构上,就能意识到这一点。)


最坏的消息:

鉴于矩阵A的内存规模,最坏的效果预计是,矩阵表示的稀疏组织存储很可能会摧毁通过numba向量化技巧在密集矩阵表示上实现的所有可能性能增益,因为几乎没有机会进行有效的内存获取缓存行重用,而且稀疏性也会破坏任何轻松实现向量化操作紧凑映射的简单方法,这些操作将很难被轻松地转换成高级CPU硬件向量处理资源。


可解决问题清单:

  • 在将向量传递到已编译的代码部分时,最好始终预先分配向量Ax = np.zeros_like( A[:,0] )并将其作为另一个参数传递,以避免重复支付额外的[PTIME,PSPACE]成本,用于创建(再次创建)新的内存分配(如果怀疑向量被用于外部协调的迭代优化过程中,则更加如此)
  • 始终最好指定 numba.jit("f8[:]( f4[:], f4[:,:], ... )") 调用接口指令,以缩小通用性,提高结果代码性能。
  • 始终查看所有可用的numba.jit()选项及其各自的默认值(可能会随版本而变化),以适应您的具体情况(禁用GIL,并更好地与numba+硬件能力对齐将始终有助于代码的数值密集部分)

@jit(   signature = [    numba.float32( numba.float32, numba.int32 ),                                   #          # [_v41] @decorator with a list of calling-signatures
                         numba.float64( numba.float64, numba.int64 )                                    #
                         ],    #__________________ a list of signatures for prepared alternative code-paths, to avoid a deferred lazy-compilation if undefined
        nopython = False,      #__________________ forces the function to be compiled in nopython mode. If not possible, compilation will raise an error.
        nogil    = False,      #__________________ tries to release the global interpreter lock inside the compiled function. The GIL will only be released if Numba can compile the function in nopython mode, otherwise a compilation warning will be printed.
        cache    = False,      #__________________ enables a file-based cache to shorten compilation times when the function was already compiled in a previous invocation. The cache is maintained in the __pycache__ subdirectory of the directory containing the source file.
        forceobj = False,      #__________________ forces the function to be compiled in object mode. Since object mode is slower than nopython mode, this is mostly useful for testing purposes.
        locals   = {}          #__________________ a mapping of local variable names to Numba Types.
        ) #____________________# [_v41] ZERO <____ TEST *ALL* CALLED sub-func()-s to @.jit() too >>>>>>>>>>>>>>>>>>>>> [DONE]
 def r...(...):
      ...

3
我认为指定签名不是一个好建议,它会阻止基于数据连续性的优化(有时会导致明显的性能下降)。此外,我不确定你为什么在这里提到GPU。问题中没有提到GPU。 - MSeifert
1
但我喜欢并行处理成本的部分,特别是常常被忽视的部分,“非常容易支付比收益多得多的成本”! - MSeifert
1
{btsdaf} - littleO
好的,你在学术界或量化记录大规模(凸)优化问题上花费了一些时间——所以让我来说一下,即使仅仅是因为“需要编写”**#pragma omp parallel for这样的自动化,也很难打破记录,因为性能瓶颈在于数据稀疏表示所带来的缓存行效率不高。**这就是高性能计算争夺终极性能的起点。 - user3666197
2
如果我理解正确的话,似乎存在一个错误的假设,即guvectorize装饰器意味着GPU计算,但这是不正确的。实际上,我经常在CPU目标上使用这样的结构。 - Michael Grant
显示剩余10条评论

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