65.9K
CodeProject 正在变化。 阅读更多。
Home

Python 中的并行性

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2019年3月29日

CPOL
viewsIcon

9931

消除误解,提供实现并行性的工具

Python* 作为一门编程语言,在工业界和学术界已经使用了将近十年。这种高生产力的语言一直是科学计算和机器学习最受欢迎的抽象之一,然而,基础的 Python 语言仍然是单线程的。那么,在单线程语言的情况下,这些领域的生产力是如何得到维持的呢?

Guido van Rossum 设计的 Python 语言,是为了在类型灵活性和可预测的、线程安全的行为与管理静态类型和线程原语的复杂性之间进行权衡。反过来,这意味着必须强制执行全局解释器锁 (GIL),以限制同一时间只有一个线程执行,从而保持这种设计理念。在过去十年中,Python 已经实现了许多并发实现——但在并行性方面却很少。这是否意味着该语言性能不佳?让我们进一步探讨。

基础语言中用于循环和其他异步或并发调用的基本构造都遵循单线程 GIL,因此即使是列表推导式,如 [x*x for x in range(0,10)],也始终是单线程的。基础语言中线程库的存在也有些误导,因为它提供了线程实现的行为,但仍然在 GIL 下运行。Python 并发未来中的许多用于近乎并行任务的功能也受 GIL 的限制。为什么这样一种富有表现力的生产力语言会限制语言遵循这些规则呢?

原因是语言设计所采取的抽象级别。它提供了许多包装 C 代码的工具,从 ctypes 到 cffi。它在基础语言中偏好使用多进程而不是多线程,这可以从原生 Python 库中的 multiprocessing 包中看出。这两种设计思想在一些流行的包中也很明显,例如 NumPy* 和 SciPy*,它们在 Python API 下使用 C 代码来调度到数学运行时库,如 Intel® 数学核心库 (Intel® MKL) 或 OpenBLAS*。社区已经采用了这种将计算调度到速度更快的 C 语言库的范例,并且这已成为在 Python 中实现并行化的首选方法。

在这些接受的方法和语言限制的结合中,存在着通过独特的并行框架来逃脱它们并在 Python 中应用并行的选项。

  • Numba* 允许对 Python 代码进行 JIT(即时)编译,该代码还可以运行基于 LLVM* 的与 Python 兼容的代码。
  • Cython* 提供了类似 Python 的语法,并带有编译后的模块,由于其编译为 C 模块,因此可以针对硬件向量化。
  • numexpr* 允许符号评估利用编译器和高级向量化。

这些方法以不同的方式绕过了 Python 的 GIL,同时保留了语言的初衷,并且这三者都实现了不同的并行模型。

让我们以一个通用的例子,即我们想要应用并行化最常见的语言构造之一——for 循环。查看下面的循环,我们可以看到它提供了一个基本服务,在列表中返回所有小于 50 的数字。

运行此代码将得到以下结果:

由于它是用纯 Python 编写的,Python 在 GIL 下以单线程方式处理项目列表。因此,它按顺序处理所有内容,并且不应用任何并行化到代码中。由于这段代码的编写方式,它是 Numba 框架的一个良好候选者。Numba 使用装饰器(带有 @ 符号)来标记要进行即时 (JIT) 编译的函数,我们将尝试将其应用于此函数。

现在运行此代码将得到以下结果:

包含这个简单的装饰器几乎使性能翻倍。这是有效的,因为原始 Python 代码是用易于编译和向量化到 CPU 的基元和数据类型编写的。Python 列表是第一个可以查看的地方。通常,这种数据结构由于其松散的类型和内置的分配器而相当笨重。然而,如果我们查看 random_list 包含的数据类型,它们都是整数。由于这种一致性,Numba 的 JIT 编译器可以对循环进行向量化。

如果列表包含混合项(例如,一个包含字符和整数的列表),编译后的代码将抛出 TypeError,因为它无法处理异构列表。此外,如果函数包含混合数据类型操作,Numba 将无法生成高性能的 JIT 编译代码,并将回退到 Python 对象代码。

这里的教训是,在 Python 中实现并行化取决于原始代码的编写方式。数据类型的清晰性和可向量化数据结构的使用允许 Numba 通过插入一个简单的装饰器来并行化代码。注意使用 Python 字典是有回报的,因为历史上它们向量化效果不佳。生成器和推导式也存在同样的问题。将此类代码重构为列表、集合或数组可以促进向量化。

在数值和符号数学中,并行化更容易实现。NumPy 和 SciPy 在将计算调度到 Python GIL 之外的底层 C 代码和 Intel MKL 运行时方面做得很好。以简单的 NumPy 符号表达式 ((2*a + 3*b)/b) 为例,如下所示:

由于 NumPy 的结构和设计,该表达式会多次通过单线程 Python 解释器。NumPy 的每次返回都会被调度到 C 并返回到 Python 层。然后,Python 对象会发送到每个后续调用,再次调度到 C。这种来回跳转成为计算中的瓶颈,因此,当您需要计算 NumPy 或 SciPy 无法描述的自定义内核时,numexpr 是一个更好的选择。

numexpr 如何实现近 4 倍的速度提升?之前的代码将计算的符号表示输入到 numexpr 的引擎中,以生成可使用 Intel MKL 中的向量数学库的向量化命令的代码。因此,整个计算保留在低级代码中,直到完成并将结果返回到 Python 层。这种方法还避免了多次通过 Python 解释器,减少了单线程部分,同时也提供了简洁的语法。

通过查看 Python 生态系统并评估不同的并行框架,可以发现存在良好的选择。要掌握 Python 并行性,了解工具及其局限性很重要。Python 选择 GIL 作为设计考虑因素,以简化框架开发并提供可预测的语言行为。但是,归根结底,通过合适的工具可以轻松绕过 GIL 及其单线程限制。

了解更多

© . All rights reserved.