雷锋网 AI 科技评论按,本文是工程师 Jim Anderson 分享的关于「通过并发性加快 python 程序的速度」的文章的第三部分,主要内容是 CPU 绑定程序加速相关。
在前面两篇中,我们已经讲过了相关的概念以及 I/O 绑定程序的加速,这篇是这一系列文章的最后一篇,讲的是 CPU 程序加速。雷锋网 AI 科技评论编译整理如下:
如何加速 CPU 绑定程序
到目前为止,前面的例子都处理了一个 I/O 绑定问题。现在,你将研究 CPU 绑定的问题。如你所见,I/O 绑定的问题大部分时间都在等待外部操作(如网络调用)完成。另一方面,CPU 限制的问题只执行很少的 I/O 操作,它的总体执行时间取决于它处理所需数据的速度。
在我们的示例中,我们将使用一个有点愚蠢的函数来创建一些需要在 CPU 上运行很长时间的东西。此函数计算从 0 到传入值的每个数字的平方和:
你将处理一大批数据,所以这需要一段时间。记住,这只是代码的一个占位符,它实际上做了一些有用的事情,需要大量的处理时间,例如计算公式的根或对大型数据结构进行排序。
CPU 绑定的同步版本
现在让我们看一下这个示例的非并发版本:
import time
def cpu_bound(number):
return sum(i * i for i in range(number))
def find_sums(numbers):
for number in numbers:
cpu_bound(number)
if __name__ == "__main__":
numbers = [5_000_000 + x for x in range(20)]
start_time = time.time()
find_sums(numbers)
duration = time.time() - start_time
print(f"Duration {duration} seconds")
此代码调用 cpu_bound() 20 次,每次使用不同的大数字。它在单个 CPU 上单个进程中的单个线程上完成所有这些工作。执行时序图如下:
与 I/O 绑定示例不同,CPU 绑定示例的运行时间通常相当一致。这台机器大约需要 7.8 秒:
显然我们可以做得更好。这都是在没有并发性的单个 CPU 上运行的。让我们看看我们能做些什么来改善它。
线程和异步版本
你认为使用线程或异步重写此代码会加快速度吗?
如果你回答「一点也不」,这是有道理的。如果你回答,「它会减慢速度,」那就更对啦。
原因如下:在上面的 I/O 绑定示例中,大部分时间都花在等待缓慢的操作完成上。线程和异步通过允许你重叠等待的时间而不是按顺序执行,这能加快速度。
但是,在 CPU 绑定的问题上,不需要等待。CPU 会尽可能快速地启动以解决问题。在 python 中,线程和任务都在同一进程中的同一个 CPU 上运行。这意味着一个 CPU 不仅做了非并发代码的所有工作,还需要做线程或任务的额外工作。它花费的时间超过 10 秒:
我已经编写了这个代码的线程版本,并将它与其他示例代码放在 Github repo 中,这样你就可以自己测试它了。
CPU 绑定的多处理版本
现在,你终于要接触多处理真正与众不同的地方啦。与其他并发库不同,多处理被显式设计为跨多个 CPU 共同承担工作负载。它的执行时序图如下所示:
它的代码是这样的:
import multiprocessing
import time
def cpu_bound(number):
return sum(i * i for i in range(number))
def find_sums(numbers):
with multiprocessing.Pool() as pool:
pool.map(cpu_bound, numbers)
if __name__ == "__main__":
numbers = [5_000_000 + x for x in range(20)]
start_time = time.time()
find_sums(numbers)
duration = time.time() - start_time
print(f"Duration {duration} seconds")
这些代码和非并发版本相比几乎没有要更改的。你必须导入多处理,然后把数字循环改为创建多处理.pool 对象,并使用其.map()方法在工作进程空闲时将单个数字发送给它们。
这正是你为 I/O 绑定的多处理代码所做的,但是这里你不需要担心会话对象。
如上所述,处理 multiprocessing.pool()构造函数的可选参数值得注意。可以指定要在池中创建和管理的进程对象的数量。默认情况下,它将确定机器中有多少 CPU,并为每个 CPU 创建一个进程。虽然这对于我们的简单示例来说很有用,但你可能希望在生产环境它也能发挥作用。
另外,和我们在第一节中提到的线程一样,multiprocessing.Pool 的代码是建立在 Queue 和 Semaphore 上的,这对于使用其他语言执行多线程和多处理代码的人来说是很熟悉的。
为什么多处理版本很重要
这个例子的多处理版本非常好,因为它相对容易设置,并且只需要很少的额外代码。它还充分利用了计算机中的 CPU 资源。在我的机器上,运行它只需要 2.5 秒:
这比我们看到的其他方法要好得多。
多处理版本的问题
使用多处理有一些缺点。在这个简单的例子中,这些缺点并没有显露出来,但是将你的问题分解开来,以便每个处理器都能独立工作有时是很困难的。此外,许多解决方案需要在流程之间进行更多的通信,这相比非并发程序来说会复杂得多。雷锋网
何时使用并发性
首先,你应该判断是否应该使用并发模块。虽然这里的示例使每个库看起来非常简单,但并发性总是伴随着额外的复杂性,并且常常会导致难以找到的错误。
坚持添加并发性,直到出现已知的性能问题,然后确定需要哪种类型的并发性。正如 DonaldKnuth 所说,「过早的优化是编程中所有灾难(或者至少大部分灾难)的根源(Premature optimization is the root of all evil (or at least most of it) in programming)」。
一旦你决定优化你的程序,弄清楚你的程序是 CPU 绑定的还是 I/O 绑定的,这就是下一步要做的事情。记住,I/O 绑定的程序是那些花费大部分时间等待事情完成的程序,而 CPU 绑定的程序则尽可能快地处理数据。
正如你所看到的,CPU 绑定的问题实际上只有在使用多处理才能解决。线程和异步根本没有帮助解决这类问题。
对于 I/O 绑定的问题,python 社区中有一个通用的经验规则:「可以使用异步,必须使用线程。」异步可以为这种类型的程序提供最佳的速度,但有时需要某些关键库来利用它。记住,任何不放弃对事件循环控制的任务都将阻塞所有其他任务。
CPU 绑定加速的内容就到此为止啦,了解更多请访问原文!
前面的部分请查看: