使用 numpy 和 scipy 进行并行编程

日期2015-10-30(最后修改),2008-03-20(创建)

多处理器和多核机器变得越来越普遍,利用它们来加快代码运行速度将是一件好事。numpy/scipy 在这方面并不完美,但有一些方法可以做到。充分利用并行处理系统的方式取决于您正在执行的任务以及您正在使用的并行系统。如果您有一个庞大而复杂的任务或一个机器集群,充分利用它需要仔细考虑。但许多任务可以以相当简单的方式并行化。

正如俗话所说,“过早优化是万恶之源”。使用多核机器最多可以提供一个与可用核心数量相同的加速因子。在考虑并行化之前,请先让您的代码正常运行。然后问问自己,您的代码是否真的需要更快。除非必须,否则不要踏上并行化的错误之路。

简单并行化

将您的工作分解成更小的工作,并同时运行它们

例如,如果您正在分析来自脉冲星巡天的数据,并且您有数千个波束要分析,每个波束需要一天时间,那么最简单(也可能是最有效)的并行化任务的方法就是简单地将每个波束作为一项工作运行。具有两个处理器的机器可以同时运行两项工作。无需担心锁定或通信;无需编写知道它在并行运行的代码。如果每个进程需要的内存与您的机器一样多,或者如果它们都是 I/O 密集型,您可能会遇到问题,但总的来说,这是一种简单而有效的方法来并行化您的代码 - 如果它有效。并非所有任务都能如此完美地划分。如果您的目标是处理单个图像,则不清楚如何在没有大量工作的情况下做到这一点。

使用并行原语

NumPy 的一大优势是您可以非常简洁地表达数组操作。例如,要计算矩阵 A 和矩阵 B 的乘积,您只需执行以下操作

>>> C = numpy.dot(A,B)

这不仅简单易读,而且由于 NumPy 知道您要进行矩阵点积,因此它可以使用从“BLAS”(基本线性代数子程序)获得的优化实现。这通常是一个经过精心调整的库,它通过利用缓存内存和汇编器实现来尽可能快地在您的硬件上运行。但是,许多架构现在都有一个 BLAS,它也利用了多核机器。如果您的 NumPy/SciPy 使用其中一个进行编译,那么 dot() 将并行计算(如果更快),而无需您执行任何操作。类似地,对于其他矩阵操作,如求逆、奇异值分解、行列式等,也是如此。例如,开源库 ATLAS 允许在编译时选择并行级别(线程数)。英特尔的专有 MKL 库提供了在运行时选择并行级别​​的可能性。还有 GOTO 库,它允许在运行时选择并行级别。这是一个商业产品,但源代码免费分发给学术用途。

最后,SciPy/NumPy 不会并行化诸如以下操作

>>> A = B + C
>>> A = numpy.sin(B)
>>> A = scipy.stats.norm.isf(B)

这些操作按顺序运行,没有利用多核机器(但请参见下文)。原则上,这可以在不进行太多工作的情况下更改。OpenMP 是 C 语言的扩展,它允许编译器为适当注释的循环(以及其他内容)生成并行化代码。如果有人坐下来注释 NumPy 中的几个核心循环(以及可能在 SciPy 中),然后使用 OpenMP 启用编译 NumPy/SciPy,那么上述所有三个操作将自动并行运行。当然,在现实中,人们希望有一些运行时控制 - 例如,如果计划在同一台多处理器机器上运行多个作业,人们可能希望关闭自动并行化。

编写多线程或多进程代码

有时您可以看到如何将您的问题分解成几个并行任务,但这些任务需要某种同步或通信。例如,您可能有一系列可以并行运行的作业,但您需要收集所有结果,进行一些汇总计算,然后启动另一批并行作业。在 Python 中有两种方法可以做到这一点,使用多个线程)或进程

线程

线程通常比进程“更轻”,并且可以比进程更快地创建、销毁和切换。它们通常是利用多核系统的首选方法。但是,使用 python 进行多线程有一个关键限制;全局解释器锁 (GIL)。由于各种原因(快速网络搜索将显示大量讨论,更不用说争论,关于为什么存在以及它是否是一个好主意),python 的实现方式使得一次只有一个线程可以访问解释器。这基本上意味着一次只有一个线程可以运行 python 代码。这几乎意味着您根本无法利用并行处理。例外情况很少但很重要:当线程等待 IO(例如,您输入内容或网络中传入内容)时,python 会释放 GIL,以便其他线程可以运行。更重要的是,当 numpy 正在执行数组操作时,python 也会释放 GIL。因此,如果您告诉一个线程执行

>>> print "%s %s %s %s and %s" %( ("spam",) *3 + ("eggs",) + ("spam",) )
>>> A = B + C
>>> print A

在打印操作和 % 格式化操作期间,没有其他线程可以执行。但在 A = B + C 期间,另一个线程可以运行 - 并且如果您以 numpy 风格编写代码,大部分计算将在 A = B + C 等几个数组操作中完成。因此,您实际上可以从使用多个线程中获得加速。

python threading 模块是标准库的一部分,提供了用于多线程的工具。鉴于上面讨论的限制,可能不值得仔细地将您的代码重写为多线程架构。但有时您可以毫不费力地进行多线程,在这种情况下,它可能是值得的。有关一个示例,请参见Cookbook/Multithreading此食谱 提供了一个 thread Pool() 接口,其 API 与进程(如下)的 API 相同,这也可能引起您的兴趣。还有ThreadPo ol 模块,它非常相似。

进程

克服上述 GIL 限制的一种方法是使用多个完整进程而不是线程。每个进程都有自己的 GIL,因此它们不会像线程那样相互阻塞。从 Python 2.6 开始,标准库包含一个 multiprocessing 模块,其接口与 threading 模块相同。对于早期版本的 Python,这可作为 processing 模块使用(Python 2.6 的 multiprocessing 模块的移植版本,适用于 Python 2.4 和 2.5,正在这里开发:multiprocessing)。可以在进程之间共享内存,包括 numpy 数组。这使得能够在没有 GIL 问题的情况下获得线程的大部分优势。它还提供了一个简单的 Pool() 接口,该接口具有类似于 Cookbook/Multithreading 示例中找到的 map 和 apply 命令。

比较

这是一个非常基本的比较,它说明了 GIL 的影响(在双核机器上)。

import numpy as np
import math
def f(x):
    print x
    y = [1]*10000000
    [math.exp(i) for i in y]
def g(x):
    print x
    y = np.ones(10000000)
    np.exp(y)


from handythread import foreach
from processing import Pool
from timings import f,g
def fornorm(f,l):
    for i in l:
        f(i)
time fornorm(g,range(100))
time fornorm(f,range(10))
time foreach(g,range(100),threads=2)
time foreach(f,range(10),threads=2)
p = Pool(2)
time p.map(g,range(100))
time p.map(f,range(100))

100 * g() 10 * f()
正常 43.5s 48s
2 个线程 31s 71.5s
2 个进程 27s 31.23

对于函数 f(),它不释放 GIL,线程实际上比串行代码执行得更差,可能是由于上下文切换的开销。但是,使用 2 个进程确实提供了显著的加速。对于使用 numpy 并释放 GIL 的函数 g(),线程和进程都提供了显著的加速,尽管多进程略快。

复杂的并行化

如果您需要复杂的并行化 - 您有一个计算集群,例如,您的作业需要频繁地相互通信 - 您需要开始考虑真正的并行编程。这是计算机科学研究生课程的主题,我不会在这里讨论它。但是,有一些 Python 工具可以用来实现您在研究生课程中学到的东西。(我可能有点夸张 - 一些并行化并不那么困难,其中一些工具使它变得相当容易。但请意识到,并行代码比串行代码更难编写和调试。)

章节作者:AMArchibald、Unknown[153]、Unknown[154]、Unknown[155]、MartinSpacek、Pauli Virtanen