原文:
annas-archive.org/md5/14cdae864340f5ff491653bf5aaa6c9c
译者:飞龙
第八章:异步 I/O
到目前为止,我们已经集中精力通过增加程序在给定时间内完成的计算周期数来加速代码。然而,在大数据时代,将相关数据传递给您的代码可能成为瓶颈,而不是代码本身。当这种情况发生时,你的程序被称为I/O 绑定;换句话说,速度受到输入/输出效率的限制。
I/O 可以对程序的流程造成相当大的负担。每当你的代码从文件中读取或向网络套接字写入时,它都必须暂停联系内核,请求实际执行读取操作,然后等待其完成。这是因为实际的读取操作不是由你的程序完成的,而是由内核完成的,因为内核负责管理与硬件的任何交互。这个额外的层次可能看起来并不像世界末日一样糟糕,尤其是当你意识到类似的操作每次分配内存时也会发生;然而,如果我们回顾一下图 1-3,我们会发现我们执行的大多数 I/O 操作都是在比 CPU 慢几个数量级的设备上进行的。因此,即使与内核的通信很快,我们也将花费相当长的时间等待内核从设备获取结果并将其返回给我们。
例如,在写入网络套接字所需的时间内,通常需要约 1 毫秒,我们可以在一台 2.4 GHz 计算机上完成 2,400,000 条指令。最糟糕的是,我们的程序在这 1 毫秒时间内大部分时间都被暂停了——我们的执行被暂停,然后我们等待一个信号表明写入操作已完成。这段时间在暂停状态中度过称为I/O 等待。
异步 I/O 帮助我们利用这段浪费的时间,允许我们在 I/O 等待状态时执行其他操作。例如,在图 8-1 中,我们看到一个程序的示例,必须运行三个任务,其中所有任务都有 I/O 等待期。如果我们串行运行它们,我们将遭受三次 I/O 等待的惩罚。然而,如果我们并行运行这些任务,我们可以通过在此期间运行另一个任务来实际隐藏等待时间。重要的是要注意,所有这些仍然发生在单个线程上,并且仍然一次只使用一个 CPU!
这种方式的实现是因为当程序处于 I/O 等待时,内核只是等待我们请求的设备(硬盘、网络适配器、GPU 等)发出信号,表示请求的数据已经准备好。我们可以创建一个机制(即事件循环)来分派数据请求,继续执行计算操作,并在数据准备好读取时得到通知,而不是等待。这与多进程/多线程(第 9 章)范式形成了鲜明对比,后者启动新进程以进行 I/O 等待,但利用现代 CPU 的多任务性质允许主进程继续。然而,这两种机制通常同时使用,其中我们启动多个进程,每个进程在异步 I/O 方面都很有效,以充分利用计算机的资源。
注意
由于并发程序在单线程上运行,通常比标准多线程程序更容易编写和管理。所有并发函数共享同一个内存空间,因此在它们之间共享数据的方式与您预期的正常方式相同。然而,您仍然需要注意竞态条件,因为不能确定代码的哪些行在何时运行。
通过以事件驱动的方式对程序建模,我们能够利用 I/O 等待,在单线程上执行比以往更多的操作。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0801.png
图 8-1. 串行和并发程序的比较
异步编程简介
通常,当程序进入 I/O 等待时,执行会暂停,以便内核执行与 I/O 请求相关的低级操作(这称为上下文切换),直到 I/O 操作完成。上下文切换是一种相当重的操作。它要求我们保存程序的状态(丢失 CPU 级别的任何缓存),并放弃使用 CPU。稍后,当我们被允许再次运行时,我们必须花时间在主板上重新初始化我们的程序,并准备恢复(当然,所有这些都是在幕后进行的)。
与并发相比,我们通常会有一个事件循环在运行,负责管理程序中需要运行的内容以及运行的时间。本质上,事件循环就是一系列需要运行的函数。列表顶部的函数被运行,然后是下一个,以此类推。示例 8-1 展示了一个简单的事件循环示例。
示例 8-1. 玩具事件循环
from queue import Queue
from functools import partial
eventloop = None
class EventLoop(Queue):
def start(self):
while True:
function = self.get()
function()
def do_hello():
global eventloop
print("Hello")
eventloop.put(do_world)
def do_world():
global eventloop
print("world")
eventloop.put(do_hello)
if __name__ == "__main__":
eventloop = EventLoop()
eventloop.put(do_hello)
eventloop.start()
这可能看起来不是一个很大的改变;然而,当我们将事件循环与异步(async)I/O 操作结合使用时,可以在执行 I/O 任务时获得显著的性能提升。在这个例子中,调用 eventloop.put(do_world)
大致相当于对 do_world
函数进行异步调用。这个操作被称为非阻塞
,意味着它会立即返回,但保证稍后某个时刻调用 do_world
。类似地,如果这是一个带有异步功能的网络写入,它将立即返回,即使写入尚未完成。当写入完成时,一个事件触发,这样我们的程序就知道了。
将这两个概念结合起来,我们可以编写一个程序,当请求 I/O 操作时,运行其他函数,同时等待原始 I/O 操作完成。这本质上允许我们在本来会处于 I/O 等待状态时仍然进行有意义的计算。
注意
切换从一个函数到另一个函数确实是有成本的。内核必须花费时间在内存中设置要调用的函数,并且我们的缓存状态不会那么可预测。正是因为这个原因,并发在程序有大量 I/O 等待时提供了最佳结果——与通过利用 I/O 等待时间获得的收益相比,切换的成本可能要少得多。
通常,使用事件循环进行编程可以采用两种形式:回调或期约。在回调范式中,函数被调用并带有一个通常称为回调的参数。函数不返回其值,而是调用回调函数并传递该值。这样设置了一长串被调用的函数链,每个函数都得到前一个函数链中的结果(这些链有时被称为“回调地狱”)。示例 8-2 是回调范式的一个简单示例。
示例 8-2. 使用回调的示例
from functools import partial
from some_database_library import save_results_to_db
def save_value(value, callback):
print(f"Saving {value} to database")
save_result_to_db(result, callback) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
def print_response(db_response):
print("Response from database: {db_response}")
if __name__ == "__main__":
eventloop.put(partial(save_value, "Hello World", print_response))
save_result_to_db
是一个异步函数;它将立即返回,并允许其他代码运行。然而,一旦数据准备好,将调用 print_response
。
在 Python 3.4 之前,回调范式非常流行。然而,asyncio
标准库模块和 PEP 492 使期约机制成为 Python 的本地特性。通过创建处理异步 I/O 的标准 API 以及新的await
和async
关键字,定义了异步函数和等待结果的方式。
在这种范式中,异步函数返回一个 Future
对象,这是一个未来结果的承诺。因此,如果我们希望在某个时刻获取结果,我们必须等待由这种类型的异步函数返回的未来完成并填充我们期望的值(通过对其进行 await
或运行显式等待值的函数)。然而,这也意味着结果可以在调用者的上下文中可用,而在回调范式中,结果仅在回调函数中可用。在等待 Future
对象填充我们请求的数据时,我们可以进行其他计算。如果将这与生成器的概念相结合——可以暂停并稍后恢复执行的函数——我们可以编写看起来非常接近串行代码形式的异步代码:
from some_async_database_library import save_results_to_db
async def save_value(value):
print(f"Saving {value} to database")
db_response = await save_result_to_db(result) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
print("Response from database: {db_response}")
if __name__ == "__main__":
eventloop.put(
partial(save_value, "Hello World", print)
)
在这种情况下,save_result_to_db
返回一个 Future
类型。通过 await
它,我们确保 save_value
在值准备好之前暂停,然后恢复并完成其操作。
重要的是要意识到,由 save_result_to_db
返回的 Future
对象保持了未来结果的承诺,并不持有结果本身或调用任何 save_result_to_db
代码。事实上,如果我们简单地执行 db_response_future = save_result_to_db(result)
,该语句会立即完成,并且我们可以对 Future
对象执行其他操作。例如,我们可以收集一个未来对象的列表,并同时等待它们的完成。
async/await 是如何工作的?
一个 async
函数(使用 async def
定义)称为协程。在 Python 中,协程的实现与生成器具有相同的哲学。这很方便,因为生成器已经有了暂停执行和稍后恢复的机制。使用这种范式,await
语句在功能上类似于 yield
语句;当前函数的执行在运行其他代码时暂停。一旦 await
或 yield
解析出数据,函数就会恢复执行。因此,在前面的例子中,我们的 save_result_to_db
将返回一个 Future
对象,而 await
语句会暂停函数,直到 Future
包含一个结果。事件循环负责安排在 Future
准备好返回结果后恢复 save_value
的执行。
对于基于 Python 2.7 实现的基于未来的并发,当我们尝试将协程用作实际函数时,事情可能会变得有些奇怪。请记住,生成器无法返回值,因此库以各种方式处理此问题。在 Python 3.4 中引入了新的机制,以便轻松创建协程并使它们仍然返回值。然而,许多自 Python 2.7 以来存在的异步库具有处理这种尴尬转换的遗留代码(特别是 tornado
的 gen
模块)。
在运行并发代码时,意识到我们依赖于事件循环是至关重要的。一般来说,这导致大多数完全并发的代码的主要代码入口主要是设置和启动事件循环。然而,这假设整个程序都是并发的。在其他情况下,程序内部会创建一组 futures,然后简单地启动一个临时事件循环来管理现有的 futures,然后事件循环退出,代码可以正常恢复。这通常使用asyncio.loop
模块中的loop.run_until_complete(coro)
或loop.run_forever()
方法来完成。然而,asyncio
还提供了一个便利函数(asyncio.run(coro)
)来简化这个过程。
本章中,我们将分析一个从具有内置延迟的 HTTP 服务器获取数据的网络爬虫。这代表了在处理 I/O 时通常会发生的响应时间延迟。我们首先会创建一个串行爬虫,查看这个问题的简单 Python 解决方案。然后,我们将通过迭代gevent
和torando
逐步构建出一个完整的aiohttp
解决方案。最后,我们将探讨如何将异步 I/O 任务与 CPU 任务结合起来,以有效地隐藏任何花费在 I/O 上的时间。
注意
我们实现的 Web 服务器可以同时支持多个连接。对于大多数需要进行 I/O 操作的服务来说,这通常是真实的情况——大多数数据库可以支持同时进行多个请求,大多数 Web 服务器支持 10,000 个以上的同时连接。然而,当与无法处理多个连接的服务进行交互时,我们的性能将始终与串行情况相同。
串行爬虫
在我们的并发实验控制中,我们将编写一个串行网络爬虫,接收一个 URL 列表,获取页面内容并计算总长度。我们将使用一个自定义的 HTTP 服务器,它接收两个参数,name
和delay
。delay
字段告诉服务器在响应之前暂停的时间长度(以毫秒为单位)。name
字段用于记录日志。
通过控制delay
参数,我们可以模拟服务器响应查询的时间。在现实世界中,这可能对应于一个响应缓慢的 Web 服务器、繁重的数据库调用,或者任何执行时间长的 I/O 调用。对于串行情况,这会导致程序在 I/O 等待中耗费更多时间,但在后面的并发示例中,这将提供一个机会来利用 I/O 等待时间来执行其他任务。
在示例 8-3 中,我们选择使用requests
模块执行 HTTP 调用。我们之所以选择这个模块,是因为它的简单性。我们在本节中通常使用 HTTP,因为它是 I/O 的一个简单示例,可以轻松执行。一般来说,可以用任何 I/O 替换对 HTTP 库的任何调用。
示例 8-3. 串行 HTTP 抓取器
import random
import string
import requests
def generate_urls(base_url, num_urls):
"""
We add random characters to the end of the URL to break any caching
mechanisms in the requests library or the server
"""
for i in range(num_urls):
yield base_url + "".join(random.sample(string.ascii_lowercase, 10))
def run_experiment(base_url, num_iter=1000):
response_size = 0
for url in generate_urls(base_url, num_iter):
response = requests.get(url)
response_size += len(response.text)
return response_size
if __name__ == "__main__":
import time
delay = 100
num_iter = 1000
base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"
start = time.time()
result = run_experiment(base_url, num_iter)
end = time.time()
print(f"Result: {result}, Time: {end - start}")
运行此代码时,一个有趣的度量标准是查看每个请求在 HTTP 服务器中的开始和结束时间。这告诉我们我们的代码在 I/O 等待期间的效率有多高——因为我们的任务是发起 HTTP 请求并汇总返回的字符数,我们应该能够在等待其他请求完成时发起更多的 HTTP 请求并处理任何响应。
我们可以在 图 8-2 中看到,正如预期的那样,我们的请求没有交错。我们一次只执行一个请求,并在移动到下一个请求之前等待前一个请求完成。实际上,串行进程的总运行时间在这种情况下是完全合理的。由于每个请求都需要 0.1 秒(因为我们的 delay
参数),而我们要执行 500 个请求,我们预计运行时间大约为 50 秒。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0802.png
图 8-2. 示例 8-3 请求时间
Gevent
最简单的异步库之一是 gevent
。它遵循异步函数返回未来任务的范例,这意味着您的代码中的大部分逻辑可以保持不变。此外,gevent
修补了标准 I/O 函数以支持异步,因此大多数情况下,您可以简单地使用标准 I/O 包并从异步行为中受益。
Gevent 提供了两种机制来实现异步编程——如前所述,它通过异步 I/O 函数修补了标准库,并且它有一个 Greenlets
对象可用于并发执行。Greenlet 是一种协程类型,可以看作是线程(请参阅 第 9 章 中有关线程的讨论);然而,所有的 Greenlet 都在同一个物理线程上运行。我们有一个事件循环在单个 CPU 上,在 I/O 等待期间能够在它们之间切换。大部分时间,gevent
试图通过使用 wait
函数尽可能透明地处理事件循环。wait
函数将启动一个事件循环,并运行它直到所有的 Greenlet 完成为止。因此,大部分 gevent
代码将会串行运行;然后在某个时刻,您会设置许多 Greenlet 来执行并发任务,并使用 wait
函数启动事件循环。在 wait
函数执行期间,您排队的所有并发任务都将运行直到完成(或某些停止条件),然后您的代码将恢复为串行。
未来任务是通过 gevent.spawn
创建的,该函数接受一个函数及其参数,并启动一个负责运行该函数的绿色线程(greenlet)。绿色线程可以被视为一个未来任务,因为一旦我们指定的函数完成,其值将包含在绿色线程的 value
字段中。
这种对 Python 标准模块的修补可能会使控制正在进行的细微变化变得更加困难。例如,在进行异步 I/O 时,我们要确保不要同时打开太多文件或连接。如果这样做,我们可能会使远程服务器过载,或者通过不得不在太多操作之间进行上下文切换来减慢我们的进程。
为了手动限制打开文件的数量,我们使用信号量一次只允许 100 个绿色线程进行 HTTP 请求。信号量通过确保只有一定数量的协程可以同时进入上下文块来工作。因此,我们立即启动所有需要获取 URL 的绿色线程;但是每次只有 100 个线程可以进行 HTTP 调用。信号量是各种并行代码流中经常使用的一种锁定机制类型。通过基于各种规则限制代码的执行顺序,锁定可以帮助您确保程序的各个组件不会互相干扰。
现在,我们已经设置好了所有的未来并且放入了一个锁机制来控制绿色线程的流程,我们可以使用gevent.iwait
函数等待,该函数将获取一个准备好的项目序列并迭代它们。相反,我们也可以使用gevent.wait
,它将阻塞程序的执行,直到所有请求都完成。
我们费力地使用信号量来分组我们的请求,而不是一次性发送它们,因为过载事件循环可能会导致性能下降(对于所有异步编程都是如此)。此外,我们与之通信的服务器将限制同时响应的并发请求数量。
通过实验(见图 8-3](#conn_num_concurrent_requests)),我们通常看到一次大约 100 个开放连接对于约 50 毫秒响应时间的请求是最佳的。如果我们使用更少的连接,我们仍然会在 I/O 等待期间浪费时间。而使用更多连接时,我们在事件循环中频繁切换上下文,并给程序增加了不必要的开销。我们可以看到,对于 50 毫秒请求,400 个并发请求的情况下,这种效果就显现出来了。话虽如此,这个 100 的值取决于许多因素——计算机运行代码的机器、事件循环的实现、远程主机的属性、远程服务器的预期响应时间等。我们建议在做出选择之前进行一些实验。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0803.png
图 8-3. 对不同数量的并发请求进行实验,针对不同的请求时间。
在示例 8-4,我们通过使用信号量来实现gevent
爬虫,以确保一次只有 100 个请求。
示例 8-4. gevent
HTTP 爬虫
import random
import string
import urllib.error
import urllib.parse
import urllib.request
from contextlib import closing
import gevent
from gevent import monkey
from gevent.lock import Semaphore
monkey.patch_socket()
def generate_urls(base_url, num_urls):
for i in range(num_urls):
yield base_url + "".join(random.sample(string.ascii_lowercase, 10))
def download(url, semaphore):
with semaphore: <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
with closing(urllib.request.urlopen(url)) as data:
return data.read()
def chunked_requests(urls, chunk_size=100):
"""
Given an iterable of urls, this function will yield back the contents of the
URLs. The requests will be batched up in "chunk_size" batches using a
semaphore
"""
semaphore = Semaphore(chunk_size) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
requests = [gevent.spawn(download, u, semaphore) for u in urls] <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png>
for response in gevent.iwait(requests):
yield response
def run_experiment(base_url, num_iter=1000):
urls = generate_urls(base_url, num_iter)
response_futures = chunked_requests(urls, 100) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png>
response_size = sum(len(r.value) for r in response_futures)
return response_size
if __name__ == "__main__":
import time
delay = 100
num_iter = 1000
base_url = f"http://127.0.0.1:8080/add?name=gevent&delay={delay}&"
start = time.time()
result = run_experiment(base_url, num_iter)
end = time.time()
print(f"Result: {result}, Time: {end - start}")
在这里,我们生成一个信号量,允许chunk_size
个下载同时进行。
通过使用信号量作为上下文管理器,我们确保一次只能运行chunk_size
个绿色线程。
我们可以排队尽可能多的绿色线程,知道没有一个会在我们用wait
或iwait
启动事件循环之前运行。
response_futures
现在持有已完成的期货生成器,所有这些期货都在.value
属性中包含了我们需要的数据。
一个重要的事情需要注意的是,我们使用了gevent
来使我们的 I/O 请求异步化,但在 I/O 等待期间我们不进行任何非 I/O 计算。然而,在图 8-4 中,我们可以看到我们得到的大幅加速(见表 8-1)。通过在等待前一个请求完成时发起更多请求,我们能够实现 90 倍的加速!我们可以明确地看到在代表请求的堆叠水平线上一次性发出请求,之前的请求完成之前。这与串行爬虫的情况形成鲜明对比(参见图 8-2),在那里一条线仅在前一条线结束时开始。
此外,我们可以在图 8-4 中看到更多有趣的效果,反映在gevent
请求时间线的形状上。例如,在大约第 100 次请求时,我们看到了一个暂停,此时没有启动新的请求。这是因为这是第一次我们的信号量被触发,并且我们能够在任何之前的请求完成之前锁定信号量。此后,信号量进入平衡状态:在另一个请求完成时刚好锁定和解锁它。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0804.png
Figure 8-4. gevent
爬虫的请求时间——红线标示第 100 次请求,我们可以看到后续请求之前的暂停。
龙卷风
另一个经常在 Python 中用于异步 I/O 的包是tornado
,最初由 Facebook 开发,主要用于 HTTP 客户端和服务器。该框架自 Python 3.5 引入async/await
以来就存在,并最初使用回调系统组织异步调用。然而,最近,项目的维护者选择采用协程,并在asyncio
模块的架构中起到了重要作用。
目前,tornado
可以通过使用 async
/await
语法(这是 Python 中的标准)或使用 Python 的 tornado.gen
模块来使用。这个模块作为 Python 中原生协程的前身提供。它通过提供一个装饰器将方法转换为协程(即,获得与使用 async def
定义函数相同结果的方法)以及各种实用工具来管理协程的运行时来实现。当前,只有在您打算支持早于 3.5 版本的 Python 时,才需要使用这种装饰器方法。¹
提示
使用 tornado
时,请确保已安装 pycurl
。它是 tornado 的可选后端,但性能更好,特别是在 DNS 请求方面,优于默认后端。
在 示例 8-5 中,我们实现了与 gevent
相同的网络爬虫,但是我们使用了 tornado
的 I/O 循环(它的版本是事件循环)和 HTTP 客户端。这使我们不必批量处理请求和处理代码的其他更底层的方面。
示例 8-5。tornado
HTTP 爬虫
import asyncio
import random
import string
from functools import partial
from tornado.httpclient import AsyncHTTPClient
AsyncHTTPClient.configure(
"tornado.curl_httpclient.CurlAsyncHTTPClient",
max_clients=100 <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
)
def generate_urls(base_url, num_urls):
for i in range(num_urls):
yield base_url + "".join(random.sample(string.ascii_lowercase, 10))
async def run_experiment(base_url, num_iter=1000):
http_client = AsyncHTTPClient()
urls = generate_urls(base_url, num_iter)
response_sum = 0
tasks = [http_client.fetch(url) for url in urls] <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
for task in asyncio.as_completed(tasks): <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png>
response = await task <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png>
response_sum += len(response.body)
return response_sum
if __name__ == "__main__":
import time
delay = 100
num_iter = 1000
run_func = partial(
run_experiment,
f"http://127.0.0.1:8080/add?name=tornado&delay={delay}&",
num_iter,
)
start = time.time()
result = asyncio.run(run_func) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/5.png>
end = time.time()
print(f"Result: {result}, Time: {end - start}")
我们可以配置我们的 HTTP 客户端,并选择我们希望使用的后端库以及我们希望将多少个请求一起批处理。Tornado 默认最多同时进行 10 个并发请求。
我们生成许多 Future
对象来排队获取 URL 内容的任务。
这将运行在 tasks
列表中排队的所有协程,并在它们完成时将它们作为结果返回。
由于协程已经完成,因此此处的 await
语句立即返回最早完成的任务的结果。
ioloop.run_sync
将启动 IOLoop
,并在指定函数的运行时段内持续运行。另一方面,ioloop.start()
启动一个必须手动终止的 IOLoop
。
在 示例 8-5 中的 tornado
代码与 示例 8-4 中的 gevent
代码之间的一个重要区别是事件循环的运行方式。对于 gevent
,事件循环仅在 iwait
函数运行时才会运行。另一方面,在 tornado
中,事件循环始终运行,并控制程序的完整执行流程,而不仅仅是异步 I/O 部分。
这使得 tornado
成为大多数 I/O 密集型应用和大多数(如果不是全部)应用都应该是异步的理想选择。这是 tornado
最为人所知的地方,作为一款高性能的 Web 服务器。事实上,Micha 在许多情况下都是用 tornado
支持的数据库和数据结构来进行大量 I/O。²
另一方面,由于 gevent
对整个程序没有任何要求,因此它是主要用于基于 CPU 的问题的理想解决方案,有时涉及大量 I/O,例如对数据集进行大量计算,然后必须将结果发送回数据库进行存储。由于大多数数据库都具有简单的 HTTP API,因此甚至可以使用 grequests
进行简化。
另一个有趣的区别在于 gevent
和 tornado
在内部更改请求调用图的方式。将 图 8-5 与 图 8-4 进行比较。对于 gevent
的调用图,我们看到一个非常均匀的调用图,即当信号量中的插槽打开时,新请求会立即发出。另一方面,tornado 的调用图则非常起伏不定。这意味着限制打开连接数的内部机制未能及时响应请求完成。调用图中那些看起来比平时更细或更粗的区域表示事件循环未能有效地执行其工作的时段——即我们要么未充分利用资源,要么过度利用资源。
注意
对于所有使用 asyncio
运行事件循环的库,我们实际上可以更改正在使用的后端库。例如,uvloop
项目提供了一个替换 asyncio
事件循环的即插即用解决方案,声称大幅提升速度。这些速度提升主要在服务器端可见;在本章概述的客户端示例中,它们只提供了小幅性能提升。然而,由于只需额外两行代码即可使用此事件循环,几乎没有理由不使用它!
我们可以开始理解这种减速的原因,考虑到我们一遍又一遍地学到的教训:通用代码之所以有用,是因为它能很好地解决所有问题,但没有完美解决任何一个单独的问题。当处理大型 Web 应用程序或代码库中可能在许多不同位置进行 HTTP 请求时,限制一百个正在进行的连接的机制非常有用。一种简单的配置保证总体上我们不会打开超过定义的连接数。然而,在我们的情况下,我们可以从处理方式非常具体的好处中受益(就像我们在 gevent
示例中所做的那样)。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0805.png
图 8-5. 示例 8-5 的 HTTP 请求时间的时间轴
aiohttp
针对使用异步功能处理重型 IO 系统的普及,Python 3.4+引入了对旧的asyncio
标准库模块的改进。然而,这个模块当时相当低级,提供了所有用于第三方库创建易于使用的异步库的底层机制。aiohttp
作为第一个完全基于新asyncio
库构建的流行库应运而生。它提供 HTTP 客户端和服务器功能,并使用与熟悉tornado
的人类似的 API。整个项目aio-libs
提供了广泛用途的原生异步库。在示例 8-6 中,我们展示了如何使用aiohttp
实现asyncio
爬虫。
示例 8-6. asyncio
HTTP 爬虫
import asyncio
import random
import string
import aiohttp
def generate_urls(base_url, num_urls):
for i in range(num_urls):
yield base_url + "".join(random.sample(string.ascii_lowercase, 10))
def chunked_http_client(num_chunks):
"""
Returns a function that can fetch from a URL, ensuring that only
"num_chunks" of simultaneous connects are made.
"""
semaphore = asyncio.Semaphore(num_chunks) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
async def http_get(url, client_session): <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
nonlocal semaphore
async with semaphore:
async with client_session.request("GET", url) as response:
return await response.content.read()
return http_get
async def run_experiment(base_url, num_iter=1000):
urls = generate_urls(base_url, num_iter)
http_client = chunked_http_client(100)
responses_sum = 0
async with aiohttp.ClientSession() as client_session:
tasks = [http_client(url, client_session) for url in urls] <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png>
for future in asyncio.as_completed(tasks): <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/4.png>
data = await future
responses_sum += len(data)
return responses_sum
if __name__ == "__main__":
import time
loop = asyncio.get_event_loop()
delay = 100
num_iter = 1000
start = time.time()
result = loop.run_until_complete(
run_experiment(
f"http://127.0.0.1:8080/add?name=asyncio&delay={delay}&", num_iter
)
)
end = time.time()
print(f"Result: {result}, Time: {end - start}")
与gevent
示例中一样,我们必须使用信号量来限制请求的数量。
我们返回一个新的协程,将异步下载文件并尊重信号量的锁定。
函数http_client
返回 futures。为了跟踪进度,我们将 futures 保存到列表中。
与gevent
一样,我们可以等待 futures 变为就绪并对其进行迭代。
对这段代码的一个直接反应是async with
、async def
和await
调用的数量。在http_get
的定义中,我们使用异步上下文管理器以并发友好的方式访问共享资源。也就是说,通过使用async with
,我们允许其他协程在等待获取我们请求的资源时运行。因此,可以更有效地共享诸如开放的信号量插槽或已经打开的连接到我们主机的东西,比我们在使用tornado
时经历的更有效。
实际上,图 8-6 中的调用图显示了与图 8-4 中的gevent
类似的平滑过渡。此外,总体上,asyncio
的代码运行速度略快于gevent
(1.10 秒对比 1.14 秒—参见表 8-1),尽管每个请求的时间稍长。这只能通过信号量暂停的协程或等待 HTTP 客户端的更快恢复来解释。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0806.png
图 8-6. 示例 8-6 的 HTTP 请求的年表
这段代码示例还展示了使用aiohttp
和使用tornado
之间的巨大区别,因为使用aiohttp
时,我们对事件循环以及我们正在进行的请求的各种微妙之处有很大的控制。例如,我们手动获取客户端会话,负责缓存打开的连接,以及手动从连接中读取。如果我们愿意,我们可以改变连接缓存的时间或者决定仅向服务器写入而不读取其响应。
尽管对于这样一个简单的示例来说,这种控制可能有点过度,但在实际应用中,我们可以使用它来真正调整我们应用程序的性能。任务可以轻松地添加到事件循环中而无需等待其响应,并且我们可以轻松地为任务添加超时,使其运行时间受限;我们甚至可以添加在任务完成时自动触发的函数。这使我们能够创建复杂的运行时模式,以最优化地利用通过能够在 I/O 等待期间运行代码而获得的时间。特别是,当我们运行一个 Web 服务时(例如,一个可能需要为每个请求执行计算任务的 API),这种控制可以使我们编写“防御性”代码,知道如何在新请求到达时将运行时间让步给其他任务。我们将在 “完全异步” 中更多地讨论这个方面。
表 8-1. 爬虫的总运行时间比较
序号 | gevent | tornado | aiohttp | |
---|---|---|---|---|
运行时间(秒) | 102.684 | 1.142 | 1.171 | 1.101 |
共享 CPU-I/O 负载
为了使前述示例更具体化,我们将创建另一个玩具问题,在这个问题中,我们有一个需要频繁与数据库通信以保存结果的 CPU 绑定问题。CPU 负载可以是任何东西;在这种情况下,我们正在使用较大和较大的工作负载因子对随机字符串进行bcrypt
哈希以增加 CPU 绑定工作的量(请参见表 8-2 以了解“难度”参数如何影响运行时间)。这个问题代表了任何需要程序进行大量计算,并且这些计算的结果必须存储到数据库中的问题,可能会导致严重的 I/O 惩罚。我们对数据库的唯一限制如下:
-
它有一个 HTTP API,因此我们可以使用早期示例中的代码。³
-
响应时间在 100 毫秒的数量级上。
-
数据库可以同时满足许多请求。⁴
这个“数据库”的响应时间被选择得比通常要高,以夸大问题的转折点,即执行 CPU 任务中的一个所需的时间长于执行 I/O 任务中的一个。对于只用于存储简单值的数据库,响应时间大于 10 毫秒应被认为是慢的!
表 8-2. 计算单个哈希的时间
难度参数 | 8 | 10 | 11 | 12 |
---|---|---|---|---|
每次迭代秒数 | 0.0156 | 0.0623 | 0.1244 | 0.2487 |
串行
我们从一些简单的代码开始,计算字符串的 bcrypt
哈希,并在计算结果时每次向数据库的 HTTP API 发送请求:
import random
import string
import bcrypt
import requests
def do_task(difficulty):
"""
Hash a random 10 character string using bcrypt with a specified difficulty
rating.
"""
passwd = ("".join(random.sample(string.ascii_lowercase, 10)) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
.encode("utf8"))
salt = bcrypt.gensalt(difficulty) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
result = bcrypt.hashpw(passwd, salt)
return result.decode("utf8")
def save_result_serial(result):
url = f"http://127.0.0.1:8080/add"
response = requests.post(url, data=result)
return response.json()
def calculate_task_serial(num_iter, task_difficulty):
for i in range(num_iter):
result = do_task(task_difficulty)
save_number_serial(result)
我们生成一个随机的 10 字符字节数组。
difficulty
参数设置了生成密码的难度,通过增加哈希算法的 CPU 和内存需求来实现。
正如我们的串行示例中一样(示例 8-3),每个数据库保存的请求时间(100 毫秒)不会叠加,我们必须为每个结果支付这个惩罚。因此,以 8 的任务难度进行六百次迭代需要 71 秒。然而,由于我们串行请求的方式,我们至少要花费 40 秒在 I/O 上!我们程序运行时的 56% 时间都在做 I/O,并且仅仅是在“I/O 等待”时,而它本可以做一些其他事情!
当然,随着 CPU 问题所需时间越来越长,做这种串行 I/O 的相对减速也会减少。这只是因为在每个任务后都暂停 100 毫秒的成本,相比于完成这个计算所需的长时间来说微不足道(正如我们在 图 8-7 中所见)。这一事实突显了在考虑进行哪些优化之前了解工作负载的重要性。如果您有一个需要几小时的 CPU 任务和仅需要几秒钟的 I/O 任务,那么加速 I/O 任务所带来的巨大提速可能并不会达到您所期望的效果!
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_08in01.png
图 8-7. 串行代码与无 I/O 的 CPU 任务比较
批处理结果
而不是立即转向完全的异步解决方案,让我们尝试一个中间解决方案。如果我们不需要立即知道数据库中的结果,我们可以批量处理结果,并以小的异步突发方式将它们发送到数据库。为此,我们创建一个 AsyncBatcher
对象,负责将结果排队,以便在没有 CPU 任务的 I/O 等待期间发送它们。在这段时间内,我们可以发出许多并发请求,而不是逐个发出它们:
import asyncio
import aiohttp
class AsyncBatcher(object):
def __init__(self, batch_size):
self.batch_size = batch_size
self.batch = []
self.client_session = None
self.url = f"http://127.0.0.1:8080/add"
def __enter__(self):
return self
def __exit__(self, *args, **kwargs):
self.flush()
def save(self, result):
self.batch.append(result)
if len(self.batch) == self.batch_size:
self.flush()
def flush(self):
"""
Synchronous flush function which starts an IOLoop for the purposes of
running our async flushing function
"""
loop = asyncio.get_event_loop()
loop.run_until_complete(self.__aflush()) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
async def __aflush(self): <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
async with aiohttp.ClientSession() as session:
tasks = [self.fetch(result, session) for result in self.batch]
for task in asyncio.as_completed(tasks):
await task
self.batch.clear()
async def fetch(self, result, session):
async with session.post(self.url, data=result) as response:
return await response.json()
我们能够启动一个事件循环来运行单个异步函数。事件循环将一直运行,直到异步函数完成,然后代码将恢复正常运行。
该函数与 示例 8-6 几乎相同。
现在我们几乎可以和以前做法一样继续。主要区别在于我们将结果添加到我们的AsyncBatcher
中,并让它负责何时发送请求。请注意,我们选择将此对象作为上下文管理器,以便一旦我们完成批处理,最终的flush()
将被调用。如果我们没有这样做,可能会出现一些结果仍在排队等待触发刷新的情况:
def calculate_task_batch(num_iter, task_difficulty):
with AsyncBatcher(100) as batcher: <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
for i in range(num_iter):
result = do_task(i, task_difficulty)
batcher.save(result)
我们选择以 100 个请求为一批处理的原因,类似于图 8-3 中所示的情况。
通过这一变更,我们将难度为 8 的运行时间缩短到了 10.21 秒。这代表了 6.95 倍的加速,而我们几乎没有做什么额外的工作。在像实时数据管道这样的受限环境中,这种额外的速度可能意味着系统能否跟得上需求的差异,并且这种情况下可能需要一个队列;在第十章中会学习到这些内容。
要理解此批处理方法的时间计时,请考虑可能影响批处理方法时间的变量。如果我们的数据库吞吐量无限(即,我们可以同时发送无限数量的请求而没有惩罚),我们可以利用我们的AsyncBatcher
满时执行刷新时只有 100 毫秒的惩罚。在这种情况下,我们可以在计算完成时一次性将所有请求保存到数据库并执行它们,从而获得最佳性能。
然而,在现实世界中,我们的数据库有最大的吞吐量限制,限制了它们可以处理的并发请求数量。在这种情况下,我们的服务器每秒限制在 100 个请求,这意味着我们必须每 100 个结果刷新我们的批处理器,并在那时进行 100 毫秒的惩罚。这是因为批处理器仍然会暂停程序的执行,就像串行代码一样;但在这个暂停的时间内,它执行了许多请求而不是只有一个。
如果我们试图将所有结果保存到最后然后一次性发出它们,服务器一次只会处理一百个,而且我们会因为同时发出所有这些请求而额外增加开销,这会导致数据库超载,可能会导致各种不可预测的减速。
另一方面,如果我们的服务器吞吐量非常差,一次只能处理一个请求,我们可能还是会串行运行我们的代码!即使我们将我们的批处理保持在每批 100 个结果,当我们实际去发起请求时,每次只会有一个请求被响应,有效地使我们所做的任何批处理无效。
这种批处理结果的机制,也被称为流水线处理,在试图降低 I/O 任务负担时非常有帮助(如图 8-8 所示)。它在异步 I/O 速度和编写串行程序的便利性之间提供了很好的折衷方案。然而,确定一次性流水线处理多少内容非常依赖于具体情况,并且需要一些性能分析和调整才能获得最佳性能。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_08in02.png
图 8-8. 批处理请求与不进行任何 I/O 的比较
完全异步
在某些情况下,我们可能需要实现一个完全异步的解决方案。如果 CPU 任务是较大的 I/O 绑定程序的一部分,例如 HTTP 服务器,则可能会发生这种情况。想象一下,你有一个 API 服务,对于其中一些端点的响应,必须执行繁重的计算任务。我们仍然希望 API 能够处理并发请求,并在其任务中表现良好,但我们也希望 CPU 任务能够快速运行。
在示例 8-7 中实现此解决方案的代码与示例 8-6 的代码非常相似。
示例 8-7. 异步 CPU 负载
def save_result_aiohttp(client_session):
sem = asyncio.Semaphore(100)
async def saver(result):
nonlocal sem, client_session
url = f"http://127.0.0.1:8080/add"
async with sem:
async with client_session.post(url, data=result) as response:
return await response.json()
return saver
async def calculate_task_aiohttp(num_iter, task_difficulty):
tasks = []
async with aiohttp.ClientSession() as client_session:
saver = save_result_aiohttp(client_session)
for i in range(num_iter):
result = do_task(i, task_difficulty)
task = asyncio.create_task(saver(result)) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
tasks.append(task)
await asyncio.sleep(0) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/2.png>
await asyncio.wait(tasks) <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/3.png>
我们不会立即await
数据库保存,而是使用asyncio.create_task
将其排入事件循环,并跟踪它,以确保任务在函数结束前已完成。
这可能是函数中最重要的一行。在这里,我们暂停主函数,以便事件循环处理任何未完成的任务。如果没有这个,我们排队的任务将直到函数结束才会运行。
在这里,我们等待任何尚未完成的任务。如果我们在for
循环中没有执行asyncio.sleep
,那么所有的保存操作将在这里发生!
在我们讨论此代码的性能特征之前,我们应该先谈谈asyncio.sleep(0)
语句的重要性。让函数睡眠零秒可能看起来很奇怪,但这是一种将函数推迟到事件循环并允许其他任务运行的方法。在异步代码中,每次运行await
语句时都会发生这种推迟。由于我们通常不会在 CPU 绑定的代码中await
,所以强制进行这种推迟非常重要,否则在 CPU 绑定的代码完成之前不会运行任何其他任务。在这种情况下,如果没有睡眠语句,所有的 HTTP 请求将会暂停,直到asyncio.wait
语句,然后所有的请求将会立即发出,这绝对不是我们想要的!
有了这种控制权,我们可以选择最佳时间推迟回到事件循环。在这样做时有很多考虑因素。由于程序的运行状态在推迟时发生变化,我们不希望在计算过程中进行推迟,可能会改变我们的 CPU 缓存。此外,推迟到事件循环会产生额外的开销,所以我们不希望太频繁地这样做。然而,在我们忙于 CPU 任务时,我们无法执行任何 I/O 任务。因此,如果我们的整个应用程序是一个 API,那么在 CPU 时间内不能处理任何请求!
我们的一般经验法则是尝试在期望每 50 到 100 毫秒迭代一次的任何循环中发出asyncio.sleep(0)
。有些应用程序使用time.perf_counter
,允许 CPU 任务在强制休眠之前具有特定的运行时间。对于这种情况,由于我们可以控制 CPU 和 I/O 任务的数量,我们只需确保休眠之间的时间与待处理的 I/O 任务完成所需的时间相符即可。
完全异步解决方案的一个主要性能优势是,在执行 CPU 工作的同时,我们可以执行所有的 I/O 操作,有效地隐藏它们不计入总运行时间(正如我们从图 8-9 中重叠的线条中可以看到的)。虽然由于事件循环的开销成本,它永远不会完全隐藏,但我们可以非常接近。事实上,对于难度为 8 的 600 次迭代,我们的代码运行速度比串行代码快 7.3 倍,并且执行其总 I/O 工作负载比批处理代码快 2 倍(而且这种优势随着迭代次数的增加而增加,因为批处理代码每次需要暂停 CPU 任务来刷新批次时都会浪费时间)。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_08in03.png
图 8-9. 使用aiohttp
解决方案进行 25 个难度为 8 的 CPU 任务的调用图—红线代表工作在 CPU 任务上的时间,蓝线代表发送结果到服务器的时间。
在调用时间线中,我们可以真正看到发生了什么。我们所做的是标记了难度为 8 的 25 个 CPU 任务的每个 CPU 和 I/O 任务的开始和结束。前几个 I/O 任务是最慢的,花费了一些时间来建立与我们服务器的初始连接。由于我们使用aiohttp
的ClientSession
,这些连接被缓存,后续对同一服务器的所有连接都要快得多。
此后,如果我们只关注蓝线,它们似乎在 CPU 任务之间非常规律地发生,几乎没有暂停。事实上,我们在任务之间并没有看到来自 HTTP 请求的 100 毫秒延迟。相反,我们看到 HTTP 请求在每个 CPU 任务的末尾快速发出,并在另一个 CPU 任务的末尾被标记为完成。
不过,我们确实看到每个单独的 I/O 任务所需的时间比服务器的 100 毫秒响应时间长。这种较长的等待时间是由我们的 asyncio.sleep(0)
语句的频率决定的(因为每个 CPU 任务有一个 await
,而每个 I/O 任务有三个),以及事件循环决定下一个任务的方式。对于 I/O 任务来说,这种额外的等待时间是可以接受的,因为它不会中断手头的 CPU 任务。事实上,在运行结束时,我们可以看到 I/O 运行时间缩短,直到最后一个 I/O 任务运行。这最后的蓝线是由 asyncio.wait
语句触发的,并且因为它是唯一剩下的任务,且永远不需要切换到其他任务,所以运行非常快速。
在图 8-10 和 8-11 中,我们可以看到这些变化如何影响不同工作负载下我们代码的运行时间总结。异步代码在串行代码上的加速效果显著,尽管我们离原始 CPU 问题的速度还有一段距离。要完全解决这个问题,我们需要使用诸如 multiprocessing
这样的模块,以便有一个完全独立的进程来处理我们程序的 I/O 负担,而不会减慢 CPU 部分的问题。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0807.png
图 8-10. 串行 I/O、批量异步 I/O、完全异步 I/O 和完全禁用 I/O 之间的处理时间差异
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0808.png
图 8-11. 批量异步、完全异步 I/O 和禁用 I/O 之间的处理时间差异
结语
在解决实际和生产系统中的问题时,通常需要与外部源通信。这个外部源可以是运行在另一台服务器上的数据库,另一个工作计算机,或者是提供必须处理的原始数据的数据服务。在这种情况下,你的问题很快可能会成为 I/O 绑定,这意味着大部分运行时间都受输入/输出处理的影响。
并发通过允许您将计算与可能的多个 I/O 操作交错,有助于处理 I/O 绑定的问题。这使您能够利用 I/O 和 CPU 操作之间的基本差异,以加快总体运行时间。
正如我们所看到的,gevent
提供了最高级别的异步 I/O 接口。另一方面,tornado
和 aiohttp
允许完全控制异步 I/O 堆栈。除了各种抽象级别外,每个库还使用不同的范式来表示其语法。然而,asyncio
是异步解决方案的绑定胶水,并提供了控制它们所有的基本机制。
我们还看到了如何将 CPU 和 I/O 任务合并在一起,以及如何考虑每个任务的各种性能特征,以便提出解决问题的好方法。虽然立即转向完全异步代码可能很吸引人,但有时中间解决方案几乎可以达到同样的效果,而不需要太多的工程负担。
在下一章中,我们将把这种从 I/O 绑定问题中交错计算的概念应用到 CPU 绑定问题上。有了这种新能力,我们不仅可以同时执行多个 I/O 操作,还可以执行许多计算操作。这种能力将使我们能够开始制作完全可扩展的程序,通过简单地添加更多的计算资源,每个资源都可以处理问题的一部分,从而实现更快的速度。
¹ 我们确信你没有这样做!
² 例如,fuggetaboutit
是一种特殊类型的概率数据结构(参见“概率数据结构”),它使用tornado IOLoop
来安排基于时间的任务。
³ 这不是必需的;它只是为了简化我们的代码。
⁴ 这对于所有分布式数据库和其他流行的数据库(例如 Postgres,MongoDB 等)都是正确的。
第九章:多进程模块
CPython 默认不使用多个 CPU。这部分是因为 Python 是在单核时代设计的,部分原因是并行化实际上可能相当难以高效实现。Python 给了我们实现的工具,但是让我们自己做选择。看到你的多核机器在长时间运行的进程中只使用一个 CPU 真是痛苦,所以在本章中,我们将回顾一些同时使用所有机器核心的方法。
注意
我们刚提到了CPython——这是我们所有人都使用的常见实现。Python 语言本身并不阻止其在多核系统上的使用。CPython 的实现不能有效地利用多核,但是未来的实现可能不受此限制。
我们生活在一个多核世界——笔记本电脑通常有 4 个核心,而 32 核心的桌面配置也是常见的。如果你的工作可以分解为在多个 CPU 上运行,而且不需要太多的工程工作,那么这是一个明智的方向值得考虑。
当 Python 用于在一组 CPU 上并行化问题时,你可以期待最多达到n倍的加速,其中n为核心数。如果你有一台四核机器,并且可以将所有四个核心用于任务,那么运行时间可能只有原始运行时间的四分之一。你不太可能看到超过 4 倍的加速;实际上,你可能会看到 3 到 4 倍的提升。
每增加一个进程都会增加通信开销并减少可用 RAM,所以你很少能获得完全n倍的加速。取决于你正在解决的问题,通信开销甚至可能非常大,以至于可以看到非常显著的减速。这些问题通常是任何并行编程的复杂性所在,通常需要算法的改变。这就是为什么并行编程通常被认为是一门艺术。
如果你对Amdahl’s law不熟悉,值得进行一些背景阅读。该定律表明,如果你的代码只有一小部分可以并行化,那么无论你投入多少 CPU,整体速度提升都不会太大。即使你的运行时间的大部分可以并行化,也有一个有限数量的 CPU 可以有效地用于使整个过程在到达收益递减点之前更快地运行。
multiprocessing
模块允许您使用基于进程和线程的并行处理,在队列中共享工作并在进程之间共享数据。它主要专注于单机多核并行性(在多机并行性方面有更好的选择)。非常常见的用法是将任务并行化到一组进程中,用于处理 CPU 密集型问题。您也可以使用 OpenMP 来并行化 I/O 密集型问题,但正如我们在第八章中所看到的,这方面有更好的工具(例如 Python 3 中的新asyncio
模块和tornado
)。
注意
OpenMP 是一个面向多核的低级接口——您可能会想集中精力在它上面,而不是multiprocessing
。我们在第七章中使用 Cython 引入了它,但在本章中我们没有涉及。multiprocessing
在更高的层面上工作,共享 Python 数据结构,而 OpenMP 在您编译为 C 后使用 C 原始对象(例如整数和浮点数)工作。仅当您正在编译代码时才使用它是有意义的;如果您不编译代码(例如,如果您使用高效的numpy
代码并且希望在许多核心上运行),那么坚持使用multiprocessing
可能是正确的方法。
要并行化您的任务,您必须以与编写串行进程的正常方式有所不同的方式思考。您还必须接受调试并行化任务更加困难—通常情况下,这可能会非常令人沮丧。我们建议保持并行性尽可能简单(即使您并没有从计算机中挤出最后一滴性能),这样可以保持您的开发速度。
并行系统中一个特别困难的话题是共享状态——感觉应该很容易,但会带来很多开销,并且很难正确实现。有许多使用情况,每种情况都有不同的权衡,所以绝对没有一个适合所有人的解决方案。在“使用进程间通信验证质数”中,我们将关注共享状态并考虑同步成本。避免共享状态将使您的生活变得更加轻松。
实际上,几乎完全可以通过要共享的状态量来分析算法在并行环境中的性能表现。例如,如果我们可以有多个 Python 进程同时解决相同的问题而不互相通信(这种情况称为尴尬并行),那么随着我们添加越来越多的 Python 进程,将不会产生太大的惩罚。
另一方面,如果每个进程都需要与其他每个 Python 进程通信,通信开销将会逐渐压倒处理并减慢速度。这意味着随着我们添加越来越多的 Python 进程,我们实际上可能会降低整体性能。
因此,有时候必须进行一些反直觉的算法更改,以有效地并行解决问题。例如,在并行解决扩散方程(第六章)时,每个进程实际上都会做一些另一个进程也在做的冗余工作。这种冗余减少了所需的通信量,并加快了整体计算速度!
以下是multiprocessing
模块的一些典型用途:
-
使用
Process
或Pool
对象对 CPU 密集型任务进行并行化处理 -
使用(奇怪命名的)
dummy
模块在Pool
中使用线程并行化 I/O 密集型任务 -
通过
Queue
共享序列化工作 -
在并行化的工作者之间共享状态,包括字节、基本数据类型、字典和列表
如果你来自一个使用线程进行 CPU 绑定任务的语言(例如 C++或 Java),你应该知道,虽然 Python 中的线程是操作系统本地的(它们不是模拟的——它们是真实的操作系统线程),但它们受到 GIL 的限制,因此一次只有一个线程可以与 Python 对象交互。
通过使用进程,我们可以并行地运行多个 Python 解释器,每个解释器都有一个私有的内存空间,有自己的 GIL,并且每个解释器都是连续运行的(因此没有 GIL 之间的竞争)。这是在 Python 中加速 CPU 密集型任务的最简单方法。如果我们需要共享状态,我们需要增加一些通信开销;我们将在“使用进程间通信验证质数”中探讨这个问题。
如果你使用numpy
数组,你可能会想知道是否可以创建一个更大的数组(例如,一个大型的二维矩阵),并让进程并行地处理数组的段。你可以,但是通过反复试验发现是很困难的,因此在“使用多进程共享 numpy 数据”中,我们将通过在四个 CPU 之间共享一个 25 GB 的numpy
数组来解决这个问题。与其发送数据的部分副本(这样至少会将 RAM 中所需的工作大小翻倍,并创建大量的通信开销),我们将数组的底层字节在进程之间共享。这是在一台机器上在本地工作者之间共享一个大型数组的理想方法。
在本章中,我们还介绍了Joblib库——这建立在multiprocessing
库的基础上,并提供了改进的跨平台兼容性,一个简单的用于并行化的 API,以及方便的缓存结果的持久性。Joblib 专为科学使用而设计,我们建议你去了解一下。
注意
在这里,我们讨论*nix-based(本章是使用 Ubuntu 编写的;代码应该在 Mac 上不变)机器上的multiprocessing
。自 Python 3.4 以来,出现在 Windows 上的怪癖已经被处理。Joblib 比multiprocessing
具有更强的跨平台支持,我们建议您在使用multiprocessing
之前先查看它。
在本章中,我们将硬编码进程的数量(NUM_PROCESSES=4
)以匹配 Ian 笔记本电脑上的四个物理核心。默认情况下,multiprocessing
将使用它能够看到的尽可能多的核心(机器呈现出八个——四个 CPU 和四个超线程)。通常情况下,除非你专门管理你的资源,否则你应该避免硬编码要创建的进程数量。
对multiprocessing
模块的概述
multiprocessing
模块提供了一个低级别的接口,用于进程和基于线程的并行处理。它的主要组件如下:
Process
当前进程的分叉副本;这将创建一个新的进程标识符,并在操作系统中作为独立的子进程运行任务。您可以启动并查询Process
的状态,并为其提供一个target
方法来运行。
Pool
将Process
或threading.Thread
API 包装为一个方便的工作池,共享一块工作并返回聚合结果。
Queue
允许多个生产者和消费者的 FIFO 队列。
Pipe
一个单向或双向通信通道,用于两个进程之间的通信。
Manager
一个高级管理接口,用于在进程之间共享 Python 对象。
ctypes
允许在进程分叉后共享原始数据类型(例如整数、浮点数和字节)。
同步原语
用于在进程之间同步控制流的锁和信号量。
注意
在 Python 3.2 中,通过PEP 3148引入了concurrent.futures
模块;这提供了multiprocessing
的核心行为,接口更简单,基于 Java 的java.util.concurrent
。它作为一个回退到早期 Python 版本的扩展可用。我们期望multiprocessing
在 CPU 密集型工作中继续被偏爱,并且如果concurrent.futures
在 I/O 绑定任务中变得更受欢迎,我们也不会感到惊讶。
在本章的其余部分,我们将介绍一系列示例,展示使用multiprocessing
模块的常见方法。
我们将使用一组进程或线程的Pool
,使用普通 Python 和numpy
以蒙特卡洛方法估算圆周率。这是一个简单的问题,复杂性易于理解,因此可以很容易地并行化;我们还可以看到使用线程与numpy
时的意外结果。接下来,我们将使用相同的Pool
方法搜索质数;我们将调查搜索质数的不可预测复杂性,并查看如何有效(以及无效!)地分割工作负载以最佳利用我们的计算资源。我们将通过切换到队列来完成质数搜索,在那里我们使用Process
对象代替Pool
并使用一系列工作和毒丸来控制工作者的生命周期。
接下来,我们将处理进程间通信(IPC),以验证一小组可能的质数。通过将每个数字的工作负载跨多个 CPU 分割,我们使用 IPC 提前结束搜索,如果发现因子,以便显著超过单 CPU 搜索进程的速度。我们将涵盖共享 Python 对象、操作系统原语和 Redis 服务器,以调查每种方法的复杂性和能力折衷。
我们可以在四个 CPU 上共享一个 25 GB 的numpy
数组,以分割大型工作负载,无需复制数据。如果您有具有可并行操作的大型数组,这种技术应该能显著加快速度,因为您需要在 RAM 中分配更少的空间并复制更少的数据。最后,我们将看看如何在进程之间同步访问文件和变量(作为Value
)而不会损坏数据,以说明如何正确锁定共享状态。
注意
PyPy(在第七章中讨论)完全支持multiprocessing
库,尽管在撰写本文时的numpy
示例尚未完全支持。如果您仅使用 CPython 代码(没有 C 扩展或更复杂的库)进行并行处理,PyPy 可能是一个快速的胜利。
使用蒙特卡洛方法估算π
我们可以通过向单位圆表示的“飞镖板”投掷数千次想象中的飞镖来估算π。落入圆边缘内的飞镖数量与落入圆外的数量之间的关系将允许我们近似π的值。
这是一个理想的首个问题,因为我们可以将总工作负载均匀分配到多个进程中,每个进程在单独的 CPU 上运行。由于每个进程的工作负载相等,因此它们将同时结束,因此我们可以在增加新的 CPU 和超线程时探索可用的加速效果。
在图 9-1 中,我们向单位正方形投掷了 10,000 个飞镖,其中一部分落入了绘制的单位圆的四分之一中。这个估计相当不准确——10,000 次飞镖投掷不能可靠地给出三位小数的结果。如果您运行您自己的代码,您会看到每次运行这个估计在 3.0 到 3.2 之间变化。
要确保前三位小数的准确性,我们需要生成 10,000,000 次随机飞镖投掷。¹ 这是非常低效的(估算π的更好方法存在),但用来演示使用multiprocessing
进行并行化的好处非常方便。
使用蒙特卡洛方法,我们使用毕达哥拉斯定理来测试一个飞镖是否落入我们的圆内:
x 2 + y 2 ≤ 1 2 = 1https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0901.png
图 9-1. 使用蒙特卡洛方法估算π
我们将在示例 9-1 中查看一个循环版本。我们将实现一个普通的 Python 版本和稍后的numpy
版本,并使用线程和进程来并行化解决方案。
使用进程和线程估算π
在本节中,我们将从一个普通的 Python 实现开始,这样更容易理解,使用循环中的浮点对象。我们将通过进程并行化,以利用所有可用的 CPU,并且在使用更多 CPU 时可视化机器的状态。
使用 Python 对象
Python 实现易于跟踪,但每个 Python 浮点对象都需要被管理、引用和依次同步,这带来了一些额外开销。这种开销减慢了我们的运行时,但却为我们赢得了思考时间,因为实现起来非常快速。通过并行化这个版本,我们可以在几乎不增加额外工作的情况下获得额外的加速。
图 9-2 展示了 Python 示例的三种实现方式:
-
不使用
multiprocessing
(称为“串行”)——在主进程中使用一个for
循环 -
使用线程
-
使用进程
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0902.png
图 9-2. 串行工作,使用线程和进程
当我们使用多个线程或进程时,我们要求 Python 计算相同数量的飞镖投掷,并在工作人员之间均匀分配工作。如果我们希望使用我们的 Python 实现总共 1 亿次飞镖投掷,并且使用两个工作者,我们将要求两个线程或两个进程生成每个工作者 5000 万次飞镖投掷。
使用一个线程大约需要 71 秒,使用更多线程时没有加速。通过使用两个或更多进程,我们使运行时更短。不使用进程或线程的成本(串行实现)与使用一个进程的成本相同。
通过使用进程,在 Ian 的笔记本上使用两个或四个核心时,我们获得线性加速。对于八个工作者的情况,我们使用了英特尔的超线程技术——笔记本只有四个物理核心,因此在运行八个进程时,速度几乎没有变化。
示例 9-1 展示了我们 pi 估算器的 Python 版本。如果我们使用线程,每个指令都受 GIL 限制,因此尽管每个线程可以在不同的 CPU 上运行,但只有在没有其他线程运行时才会执行。进程版本不受此限制,因为每个分叉进程都有一个作为单个线程运行的私有 Python 解释器——没有 GIL 竞争,因为没有共享对象。我们使用 Python 的内置随机数生成器,但请参阅“并行系统中的随机数”以了解并行化随机数序列的危险注意事项。
示例 9-1. 在 Python 中使用循环估算 pi
def estimate_nbr_points_in_quarter_circle(nbr_estimates):
"""Monte Carlo estimate of the number of points in a
quarter circle using pure Python"""
print(f"Executing estimate_nbr_points_in_quarter_circle \
with {nbr_estimates:,} on pid {os.getpid()}")
nbr_trials_in_quarter_unit_circle = 0
for step in range(int(nbr_estimates)):
x = random.uniform(0, 1)
y = random.uniform(0, 1)
is_in_unit_circle = x * x + y * y <= 1.0
nbr_trials_in_quarter_unit_circle += is_in_unit_circle
return nbr_trials_in_quarter_unit_circle
示例 9-2 显示了__main__
块。请注意,在启动计时器之前,我们构建了Pool
。生成线程相对即时;生成进程涉及分叉,这需要测量时间的一部分。我们在图 9-2 中忽略了这个开销,因为这个成本将是整体执行时间的一小部分。
示例 9-2. 使用循环估计圆周率的主要代码
from multiprocessing import Pool
...
if __name__ == "__main__":
nbr_samples_in_total = 1e8
nbr_parallel_blocks = 4
pool = Pool(processes=nbr_parallel_blocks)
nbr_samples_per_worker = nbr_samples_in_total / nbr_parallel_blocks
print("Making {:,} samples per {} worker".format(nbr_samples_per_worker,
nbr_parallel_blocks))
nbr_trials_per_process = [nbr_samples_per_worker] * nbr_parallel_blocks
t1 = time.time()
nbr_in_quarter_unit_circles = pool.map(estimate_nbr_points_in_quarter_circle,
nbr_trials_per_process)
pi_estimate = sum(nbr_in_quarter_unit_circles) * 4 / float(nbr_samples_in_total)
print("Estimated pi", pi_estimate)
print("Delta:", time.time() - t1)
我们创建一个包含nbr_estimates
除以工作程序数的列表。这个新参数将发送给每个工作程序。执行后,我们将收到相同数量的结果;我们将这些结果相加以估计单位圆中的飞镖数。
我们从multiprocessing
中导入基于进程的Pool
。我们也可以使用from multiprocessing.dummy import Pool
来获取一个线程化版本。 “dummy”名称相当误导(我们承认我们不理解为什么它以这种方式命名);它只是一个围绕threading
模块的轻量级包装,以呈现与基于进程的Pool
相同的接口。
警告
我们创建的每个进程都会从系统中消耗一些 RAM。您可以预期使用标准库的分叉进程将占用大约 10-20 MB 的 RAM;如果您使用许多库和大量数据,则可能每个分叉副本将占用数百兆字节。在具有 RAM 约束的系统上,这可能是一个重大问题 - 如果 RAM 用完,系统将回到使用磁盘的交换空间,那么任何并行化优势都将因缓慢的 RAM 回写到磁盘而大量丧失!
下图绘制了 Ian 笔记本电脑的四个物理核心及其四个关联的超线程的平均 CPU 利用率(每个超线程在物理核心中的未使用硅上运行)。这些图表收集的数据包括第一个 Python 进程的启动时间和启动子进程的成本。CPU 采样器记录笔记本电脑的整个状态,而不仅仅是此任务使用的 CPU 时间。
请注意,以下图表是使用比图 9-2 慢一些的采样率创建的,因此整体运行时间略长。
在图 9-3 中,使用一个进程在Pool
(以及父进程)中的执行行为显示出一些开销,在创建Pool
时首几秒钟内,然后在整个运行过程中保持接近 100%的 CPU 利用率。使用一个进程,我们有效地利用了一个核心。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0903.png
图 9-3. 使用 Python 对象和一个进程估计圆周率
接下来,我们将添加第二个进程,相当于说Pool(processes=2)
。如你在图 9-4 中所见,添加第二个进程将执行时间大致减半至 37 秒,并且两个 CPU 完全被占用。这是我们能期待的最佳结果——我们已经高效地利用了所有新的计算资源,而且没有因通信、分页到磁盘或与竞争使用相同 CPU 的其他进程的争用等开销而损失速度。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0904.png
图 9-4. 使用 Python 对象和两个进程估算 Pi
图 9-5 显示了在使用所有四个物理 CPU 时的结果——现在我们正在使用这台笔记本电脑的全部原始计算能力。执行时间大约是单进程版本的四分之一,为 19 秒。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0905.png
图 9-5. 使用 Python 对象和四个进程估算 Pi
通过切换到八个进程,如图 9-6 所示,与四进程版本相比,我们不能实现更大的速度提升。这是因为四个超线程只能从 CPU 上的备用硅中挤出一点额外的处理能力,而四个 CPU 已经达到最大利用率。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0906.png
图 9-6. 使用 Python 对象和八个进程估算 Pi,但额外收益微乎其微
这些图表显示,我们在每个步骤中都有效地利用了更多的可用 CPU 资源,并且超线程资源是一个糟糕的补充。在使用超线程时最大的问题是 CPython 使用了大量 RAM——超线程对缓存不友好,因此每个芯片上的备用资源利用非常低效。正如我们将在下一节看到的,numpy
更好地利用了这些资源。
注意
根据我们的经验,如果有足够的备用计算资源,超线程可以提供高达 30%的性能增益如果有浮点和整数算术的混合而不仅仅是我们这里的浮点操作。通过混合资源需求,超线程可以同时调度更多 CPU 硅工作。一般来说,我们将超线程视为额外的奖励而不是需要优化的资源,因为添加更多 CPU 可能比调整代码(增加支持开销)更经济。
现在我们将切换到一个进程中使用线程,而不是多个进程。
图 9-7 显示了在同一个代码中运行相同的代码的结果,我们用线程代替进程。尽管使用了多个 CPU,但它们轻度共享负载。如果每个线程都在没有 GIL 的情况下运行,那么我们将在四个 CPU 上看到 100%的 CPU 利用率。相反,每个 CPU 都被部分利用(因为有 GIL)。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0907.png
图 9-7. 使用 Python 对象和四个线程估算 Pi
用 Joblib 替换 multiprocessing
Joblib 是multiprocessing
的改进版本,支持轻量级流水线处理,专注于简化并行计算和透明的基于磁盘的缓存结果。它专注于科学计算中的 NumPy 数组。如果您正在使用纯 Python 编写,无论是否使用 NumPy,来处理可以简单并行化的循环,它可能会为您带来快速成功。
-
使用 Python 纯代码时,无论是否使用 NumPy,都可以处理可以使人尴尬的并行化循环。
-
调用昂贵的没有副作用的函数,其中输出可以在会话之间缓存到磁盘
-
能够在进程之间共享 NumPy 数据,但不知道如何操作(并且您尚未阅读过“使用多进程共享 NumPy 数据”)。
Joblib 基于 Loky 库构建(它本身是 Python concurrent.futures
的改进),并使用cloudpickle
来实现在交互作用域中定义函数的序列化。这解决了使用内置multiprocessing
库时遇到的几个常见问题。
对于并行计算,我们需要Parallel
类和delayed
装饰器。Parallel
类设置了一个进程池,类似于我们在前一节中使用的multiprocessing
的pool
。delayed
装饰器包装了我们的目标函数,使其可以通过迭代器应用于实例化的Parallel
对象。
语法有点令人困惑——看看示例 9-3。调用写在一行上;这包括我们的目标函数estimate_nbr_points_in_quarter_circle
和迭代器(delayed(...)(nbr_samples_per_worker) for sample_idx in range(nbr_parallel_blocks))
。让我们来分解一下这个过程。
示例 9-3. 使用 Joblib 并行化估算 Pi
...
from joblib import Parallel, delayed
if __name__ == "__main__":
...
nbr_in_quarter_unit_circles = Parallel(n_jobs=nbr_parallel_blocks, verbose=1) \
(delayed(estimate_nbr_points_in_quarter_circle)(nbr_samples_per_worker) \
for sample_idx in range(nbr_parallel_blocks))
...
Parallel
是一个类;我们可以设置参数,如n_jobs
来指定将运行多少进程,以及像verbose
这样的可选参数来获取调试信息。其他参数可以设置超时时间,选择线程或进程之间切换,更改后端(这可以帮助加速某些极端情况),并配置内存映射。
Parallel
具有一个可调用方法__call__
,接受一个可迭代对象。我们在以下圆括号中提供了可迭代对象(... for sample_idx in range(...))
。该可调用对象迭代每个delayed(estimate_nbr_points_in_quarter_circle)
函数,批处理这些函数的参数执行(在本例中为nbr_samples_per_worker
)。Ian 发现逐步构建并行调用非常有帮助,从一个没有参数的函数开始,根据需要逐步构建参数。这样可以更容易地诊断错误步骤。
nbr_in_quarter_unit_circles
将是一个包含每次调用正例计数的列表,如前所述。示例 9-4 显示了八个并行块的控制台输出;每个进程 ID(PID)都是全新创建的,并且在输出末尾打印了进度条的摘要。总共需要 19 秒,与我们在前一节中创建自己的Pool
时相同的时间。
提示
避免传递大结构;将大型 Pickle 对象传递给每个进程可能会很昂贵。Ian 曾经有过一个预先构建的 Pandas DataFrame 字典对象的情况;通过Pickle
模块对其进行序列化的成本抵消了并行化带来的收益,而串行版本实际上总体上运行得更快。在这种情况下的解决方案是使用 Python 的内置shelve
模块构建 DataFrame 缓存,并将字典存储到文件中。每次调用目标函数时,使用shelve
加载单个 DataFrame;几乎不需要传递任何东西给函数,然后Joblib
的并行化效益变得明显。
示例 9-4. Joblib
调用的输出
Making 12,500,000 samples per 8 worker
[Parallel(n_jobs=8)]: Using backend LokyBackend with 8 concurrent workers.
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10313
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10315
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10311
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10316
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10312
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10314
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10317
Executing estimate_nbr_points_in_quarter_circle with 12,500,000 on pid 10318
[Parallel(n_jobs=8)]: Done 2 out of 8 | elapsed: 18.9s remaining: 56.6s
[Parallel(n_jobs=8)]: Done 8 out of 8 | elapsed: 19.3s finished
Estimated pi 3.14157744
Delta: 19.32842755317688
提示
为了简化调试,我们可以设置n_jobs=1
,并且并行化代码被禁用。您不必进一步修改代码,可以在函数中放置一个breakpoint()
调用以便轻松调试。
函数调用结果的智能缓存
Joblib 中一个有用的功能是Memory
缓存;这是一个基于输入参数将函数结果缓存到磁盘缓存的装饰器。此缓存在 Python 会话之间持久存在,因此如果关闭计算机然后第二天运行相同的代码,将使用缓存的结果。
对于我们的π估计,这提出了一个小问题。我们不会向estimate_nbr_points_in_quarter_circle
传递唯一的参数;对于每次调用,我们都会传递nbr_estimates
,因此调用签名相同,但我们寻求的是不同的结果。
在这种情况下,一旦第一次调用完成(大约需要 19 秒),任何使用相同参数的后续调用都将获得缓存的结果。这意味着如果我们第二次运行代码,它将立即完成,但每次调用只使用八个样本结果中的一个作为结果——这显然破坏了我们的蒙特卡洛抽样!如果最后一个完成的进程导致在四分之一圆中有9815738
个点,则函数调用的缓存将始终回答这个结果。重复调用八次将生成[9815738, 9815738, 9815738, 9815738, 9815738, 9815738, 9815738, 9815738]
,而不是八个唯一的估计值。
示例 9-5 中的解决方案是传入第二个参数idx
,它接受 0 到nbr_parallel_blocks-1
之间的值。这些唯一参数的组合将允许缓存存储每个正计数,因此在第二次运行时,我们得到与第一次运行相同的结果,但无需等待。
这是通过Memory
配置的,它需要一个用于持久化函数结果的文件夹。这种持久性在 Python 会话之间保持;如果更改被调用的函数或清空缓存文件夹中的文件,则会刷新它。
请注意,此刷新仅适用于已装饰函数的更改(在本例中为estimate_nbr_points_in_quarter_circle_with_idx
),而不适用于从该函数内部调用的任何子函数。
示例 9-5. 使用 Joblib 缓存结果
...
from joblib import Memory
memory = Memory("./joblib_cache", verbose=0)
@memory.cache
def estimate_nbr_points_in_quarter_circle_with_idx(nbr_estimates, idx):
print(f"Executing estimate_nbr_points_in_quarter_circle with \
{nbr_estimates} on sample {idx} on pid {os.getpid()}")
...
if __name__ == "__main__":
...
nbr_in_quarter_unit_circles = Parallel(n_jobs=nbr_parallel_blocks) \
(delayed(
estimate_nbr_points_in_quarter_circle_with_idx) \
(nbr_samples_per_worker, idx) for idx in range(nbr_parallel_blocks))
...
在示例 9-6 中,我们可以看到,第一次调用花费了 19 秒,而第二次调用仅花费了几分之一秒,并且估算的π值相同。在这次运行中,估计值为[9817605, 9821064, 9818420, 9817571, 9817688, 9819788, 9816377, 9816478]
。
示例 9-6. 由于缓存结果,代码的零成本第二次调用
$ python pi_lists_parallel_joblib_cache.py
Making 12,500,000 samples per 8 worker
Executing estimate_nbr_points_in_... with 12500000 on sample 0 on pid 10672
Executing estimate_nbr_points_in_... with 12500000 on sample 1 on pid 10676
Executing estimate_nbr_points_in_... with 12500000 on sample 2 on pid 10677
Executing estimate_nbr_points_in_... with 12500000 on sample 3 on pid 10678
Executing estimate_nbr_points_in_... with 12500000 on sample 4 on pid 10679
Executing estimate_nbr_points_in_... with 12500000 on sample 5 on pid 10674
Executing estimate_nbr_points_in_... with 12500000 on sample 6 on pid 10673
Executing estimate_nbr_points_in_... with 12500000 on sample 7 on pid 10675
Estimated pi 3.14179964
Delta: 19.28862953186035
$ python %run pi_lists_parallel_joblib_cache.py
Making 12,500,000 samples per 8 worker
Estimated pi 3.14179964
Delta: 0.02478170394897461
Joblib 用一个简单(尽管有点难以阅读)的接口包装了许多multiprocessing
功能。Ian 已经开始使用 Joblib 来替代multiprocessing
,他建议你也试试。
并行系统中的随机数
生成良好的随机数序列是一个棘手的问题,如果尝试自行实现很容易出错。在并行中快速获得良好的序列更难——突然间,您必须担心是否会在并行进程中获得重复或相关的序列。
我们在示例 9-1 中使用了 Python 内置的随机数生成器,在下一节中的示例 9-7 中,我们将使用numpy
的随机数生成器。在这两种情况下,随机数生成器都在其分叉进程中进行了种子化。对于 Python 的random
示例,种子化由multiprocessing
内部处理——如果在分叉时看到random
存在于命名空间中,它将强制调用以在每个新进程中种子化生成器。
提示
在并行化函数调用时设置 numpy 种子。在接下来的 numpy
示例中,我们必须显式设置随机数种子。如果您忘记使用 numpy
设置随机数序列的种子,每个分叉进程将生成相同的随机数序列 —— 它看起来会按照您期望的方式工作,但在背后每个并行进程将以相同的结果演化!
如果您关心并行进程中使用的随机数质量,请务必研究这个话题。可能 numpy
和 Python 的随机数生成器已经足够好,但如果重要的结果依赖于随机序列的质量(例如医疗或金融系统),那么您必须深入了解这个领域。
在 Python 3 中,使用 Mersenne Twister 算法 —— 它具有长周期,因此序列在很长时间内不会重复。它经过了大量测试,因为它也被其他语言使用,并且是线程安全的。但可能不适合用于加密目的。
使用 numpy
在本节中,我们转而使用 numpy
。我们的投镖问题非常适合 numpy
的向量化操作——我们的估算比之前的 Python 示例快 25 倍。
numpy
比纯 Python 解决同样问题更快的主要原因是,numpy
在连续的 RAM 块中以非常低的级别创建和操作相同的对象类型,而不是创建许多需要单独管理和寻址的更高级别的 Python 对象。
由于 numpy
更加友好于缓存,当使用四个超线程时我们也会得到轻微的速度提升。在纯 Python 版本中,我们没有得到这个好处,因为较大的 Python 对象未能有效利用缓存。
在 图 9-8 中,我们看到三种情况:
-
不使用
multiprocessing
(名为“串行”) -
使用线程
-
使用进程
串行和单工作者版本的执行速度相同——使用 numpy
时没有使用线程的额外开销(并且只有一个工作者时也没有收益)。
当使用多个进程时,我们看到每个额外 CPU 的经典 100% 利用率。结果与图 9-3、9-4、9-5 和 9-6 中显示的情况相似,但使用 numpy
的代码速度要快得多。
有趣的是,线程版本随着线程数的增加运行得更快。正如在 SciPy wiki 上讨论的那样,通过在全局解释器锁之外工作,numpy
可以实现一定程度的额外加速。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0908.png
图 9-8. 使用 numpy 串行工作、使用线程和使用进程
使用进程给我们带来可预测的加速,就像在纯 Python 示例中一样。第二个 CPU 会将速度提高一倍,而使用四个 CPU 会将速度提高四倍。
示例 9-7 展示了我们代码的向量化形式。请注意,当调用此函数时,随机数生成器会被种子化。对于多线程版本,这不是必要的,因为每个线程共享同一个随机数生成器,并且它们是串行访问的。对于进程版本,由于每个新进程都是一个分支,所有分叉版本都将共享相同的状态。这意味着每个版本中的随机数调用将返回相同的序列!
提示
记得使用numpy
为每个进程调用seed()
来确保每个分叉进程生成唯一的随机数序列,因为随机源用于为每次调用设置种子。回顾一下“并行系统中的随机数”中有关并行随机数序列危险的注意事项。
示例 9-7. 使用 numpy
估算 π
def estimate_nbr_points_in_quarter_circle(nbr_samples):
"""Estimate pi using vectorized numpy arrays"""
np.random.seed() # remember to set the seed per process
xs = np.random.uniform(0, 1, nbr_samples)
ys = np.random.uniform(0, 1, nbr_samples)
estimate_inside_quarter_unit_circle = (xs * xs + ys * ys) <= 1
nbr_trials_in_quarter_unit_circle = np.sum(estimate_inside_quarter_unit_circle)
return nbr_trials_in_quarter_unit_circle
简短的代码分析表明,在此计算机上,使用多个线程执行时,对random
的调用速度稍慢,而对(xs * xs + ys * ys) <= 1
的调用可以很好地并行化。对随机数生成器的调用受制于 GIL,因为内部状态变量是一个 Python 对象。
理解这个过程是基本但可靠的:
-
注释掉所有
numpy
行,并使用串行版本在无线程下运行。运行多次,并在__main__
中使用time.time()
记录执行时间。 -
在添加一行返回代码(我们首先添加了
xs = np.random.uniform(...)
)并运行多次,再次记录完成时间。 -
添加下一行代码(现在添加
ys = ...
),再次运行,并记录完成时间。 -
重复,包括
nbr_trials_in_quarter_unit_circle = np.sum(...)
行。 -
再次重复此过程,但这次使用四个线程。逐行重复。
-
比较无线程和四个线程在每个步骤的运行时差异。
因为我们正在并行运行代码,所以使用line_profiler
或cProfile
等工具变得更加困难。记录原始运行时间并观察使用不同配置时的行为差异需要耐心,但可以提供可靠的证据来得出结论。
注意
如果你想了解uniform
调用的串行行为,请查看numpy
源码中的mtrand
代码,并跟随在mtrand.pyx中的def uniform
调用。如果你以前没有查看过numpy
源代码,这是一个有用的练习。
在构建numpy
时使用的库对于某些并行化机会非常重要。取决于构建numpy
时使用的底层库(例如是否包含了英特尔数学核心库或 OpenBLAS 等),您将看到不同的加速行为。
您可以使用numpy.show_config()
检查您的numpy
配置。如果您对可能性感到好奇,Stack Overflow 上有一些示例时间。只有一些numpy
调用会从外部库的并行化中受益。
寻找素数
接下来,我们将测试大范围内的素数。这与估算π的问题不同,因为工作量取决于您在数字范围中的位置,每个单独数字的检查具有不可预测的复杂性。我们可以创建一个串行程序来检查素数性质,然后将可能的因子集传递给每个进程进行检查。这个问题是令人尴尬地并行的,这意味着没有需要共享的状态。
multiprocessing
模块使得控制工作负载变得容易,因此我们将调查如何调整工作队列以使用(和滥用!)我们的计算资源,并探索一种更有效地利用我们资源的简单方法。这意味着我们将关注负载平衡,试图将我们变化复杂度的任务有效地分配给我们固定的资源集。
我们将使用一个与本书中稍有不同的算法(见“理想化计算与 Python 虚拟机”);如果我们有一个偶数,它会提前退出——见示例 9-8。
示例 9-8. 使用 Python 查找素数
def check_prime(n):
if n % 2 == 0:
return False
for i in range(3, int(math.sqrt(n)) + 1, 2):
if n % i == 0:
return False
return True
用这种方法测试素数时,我们能看到工作负载的多大变化?图 9-9 显示了检查素数的时间成本随可能是素数的n
从10,000
增加到1,000,000
而增加。
大多数数字都不是素数;它们用一个点表示。有些检查起来很便宜,而其他则需要检查许多因子。素数用一个x
表示,并形成浓密的黑带;检查它们是最昂贵的。随着n
的增加,检查一个数字的时间成本增加,因为要检查的可能因子范围随着n
的平方根增加。素数序列是不可预测的,因此我们无法确定一系列数字的预期成本(我们可以估计,但无法确定其复杂性)。
对于图表,我们对每个n
进行了两百次测试,并选择最快的结果来消除结果的抖动。如果我们只取一个结果,由于其他进程的系统负载,计时会出现广泛变化;通过多次读数并保留最快的结果,我们可以看到预期的最佳情况计时。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0909.png
图 9-9. 随着n
的增加,检查素数所需的时间
当我们将工作分配给一个进程池时,我们可以指定每个工作人员处理多少工作。我们可以均匀分配所有工作并争取一次通过,或者我们可以制作许多工作块并在 CPU 空闲时将它们传递出去。这由chunksize
参数控制。更大的工作块意味着更少的通信开销,而更小的工作块意味着更多地控制资源分配方式。
对于我们的素数查找器,一个单独的工作是由check_prime
检查的数字n
。chunksize
为10
意味着每个进程处理一个包含 10 个整数的列表,一次处理一个列表。
在 图 9-10 中,我们可以看到从1
(每个作业是单独的工作)到64
(每个作业是包含 64 个数字的列表)变化chunksize
的效果。尽管有许多微小的作业给了我们最大的灵活性,但也带来了最大的通信开销。四个 CPU 将被有效利用,但是通信管道会成为瓶颈,因为每个作业和结果都通过这个单一通道传递。如果我们将chunksize
加倍到2
,我们的任务完成速度将加快一倍,因为通信管道上的竞争减少了。我们可能天真地假设通过增加chunksize
,我们将继续改善执行时间。然而,正如你在图中看到的,我们最终会遇到收益递减的点。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0910.png
图 9-10. 选择合理的chunksize
值
我们可以继续增加chunksize
,直到我们开始看到行为恶化。在 图 9-11 中,我们扩展了chunksize
范围,使它们不仅小而且巨大。在较大的端点上,最坏的结果显示为 1.08 秒,我们要求chunksize
为50000
——这意味着我们的 100,000 个项目被分成两个工作块,使得两个 CPU 在整个通过过程中空闲。使用chunksize
为10000
项,我们正在创建十个工作块;这意味着四个工作块将在并行中运行两次,然后是剩下的两个工作块。这在第三轮工作中使得两个 CPU 空闲,这是资源使用的低效方式。
在这种情况下的最佳解决方案是将总作业数除以 CPU 数量。这是multiprocessing
的默认行为,显示为图中的“default”蓝点。
作为一般规则,默认行为是明智的;只有当您预计真正获益时才调整它,并且一定要针对默认行为确认您的假设。
与蒙特卡罗π问题不同,我们的素数测试计算具有不同的复杂性——有时一个工作很快退出(检测到偶数),有时数字很大且是素数(这需要更长时间来检查)。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0911.png
图 9-11. 选择合理的chunksize
值(续)
如果我们随机化我们的工作序列会发生什么?对于这个问题,我们挤出了 2% 的性能增益,正如您在图 9-12 中所看到的。通过随机化,我们减少了最后一个作业花费更长时间的可能性,使除一个 CPU 外的所有 CPU 都保持活跃。
正如我们之前使用chunksize
为10000
的示例所示,将工作量与可用资源数量不匹配会导致效率低下。在那种情况下,我们创建了三轮工作:前两轮使用了资源的 100%,而最后一轮仅使用了 50%。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0912.png
图 9-12. 随机化工作序列
图 9-13 展示了当我们将工作块的数量与处理器数量不匹配时出现的奇特效果。不匹配会导致可用资源利用不足。当只创建一个工作块时,总运行时间最慢:这样会使得三个处理器未被利用。两个工作块会使得两个 CPU 未被利用,依此类推;只有当我们有四个工作块时,我们才能充分利用所有资源。但是如果我们添加第五个工作块,我们又会浪费资源 —— 四个 CPU 将处理它们的工作块,然后一个 CPU 将用于计算第五个工作块。
随着工作块数量的增加,我们看到效率的不足减少 —— 在 29 和 32 个工作块之间的运行时间差约为 0.03 秒。一般规则是为了有效利用资源,如果您的工作具有不同的运行时间,则制作大量小型工作。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0913.png
图 9-13. 选择不合适的工作块数量的危险
以下是一些有效使用multiprocessing
处理尴尬并行问题的策略:
-
将你的工作分成独立的工作单元。
-
如果您的工作人员需要不同的时间量,请考虑随机化工作序列(另一个例子是处理不同大小的文件)。
-
对工作队列进行排序,以便最慢的工作先进行,可能是一个同样有效的策略。
-
除非您已验证调整的原因,否则请使用默认的
chunksize
。 -
将工作数量与物理 CPU 数量对齐。(再次强调,默认的
chunksize
会为您处理这个问题,尽管它默认使用超线程,这可能不会提供额外的增益。)
注意,默认情况下,multiprocessing
将超线程视为额外的 CPU。这意味着在 Ian 的笔记本电脑上,它会分配八个进程,但只有四个进程会以 100% 的速度运行。额外的四个进程可能会占用宝贵的内存,而几乎没有提供额外的速度增益。
使用Pool
,我们可以将预定义的工作块提前分配给可用的 CPU。然而,如果我们有动态工作负载,特别是随时间到达的工作负载,则这种方法帮助较少。对于这种类型的工作负载,我们可能需要使用下一节介绍的Queue
。
工作队列
multiprocessing.Queue
对象提供给我们非持久化队列,可以在进程之间发送任何可 pickle 的 Python 对象。它们带来一些额外开销,因为每个对象必须被 pickled 以便发送,然后在消费者端进行反序列化(还伴随一些锁操作)。在接下来的示例中,我们将看到这种成本是不可忽视的。然而,如果您的工作进程处理的是较大的作业,通信开销可能是可以接受的。
使用队列进行工作相当容易。在本示例中,我们将通过消费候选数列表来检查素数,并将确认的质数发布回definite_primes_queue
。我们将以单进程、双进程、四进程和八进程运行此示例,并确认后三种方法的时间比仅运行检查相同范围的单个进程更长。
Queue
为我们提供了使用本地 Python 对象进行大量进程间通信的能力。如果您传递的对象具有大量状态,这可能非常有用。然而,由于Queue
缺乏持久性,您可能不希望将其用于可能需要在面对故障时保持鲁棒性的作业(例如,如果断电或硬盘损坏)。
示例 9-9 展示了check_prime
函数。我们已经熟悉基本的素数测试。我们在一个无限循环中运行,在possible_primes_queue.get()
上阻塞(等待直到有可用的工作),以消费队列中的项目。由于Queue
对象负责同步访问,因此一次只能有一个进程获取项目。如果队列中没有工作,.get()
将阻塞,直到有任务可用。当找到质数时,它们会被put
回definite_primes_queue
,供父进程消费。
示例 9-9。使用两个队列进行进程间通信(IPC)
FLAG_ALL_DONE = b"WORK_FINISHED"
FLAG_WORKER_FINISHED_PROCESSING = b"WORKER_FINISHED_PROCESSING"
def check_prime(possible_primes_queue, definite_primes_queue):
while True:
n = possible_primes_queue.get()
if n == FLAG_ALL_DONE:
# flag that our results have all been pushed to the results queue
definite_primes_queue.put(FLAG_WORKER_FINISHED_PROCESSING)
break
else:
if n % 2 == 0:
continue
for i in range(3, int(math.sqrt(n)) + 1, 2):
if n % i == 0:
break
else:
definite_primes_queue.put(n)
我们定义了两个标志:一个由父进程作为毒丸喂入,指示没有更多工作可用,另一个由工作进程确认它已看到毒丸并关闭自身。第一个毒丸也被称为sentinel,因为它保证了处理循环的终止。
处理工作队列和远程工作者时,使用这些标志记录毒丸的发送并检查子进程在合理时间窗口内发送的响应可以很有帮助,表明它们正在关闭。我们在这里不处理这个过程,但是添加一些时间记录是代码的一个相当简单的补充。接收这些标志的情况可以在调试期间记录或打印。
Queue
对象是在 示例 9-10 中由 Manager
创建的。我们将使用构建 Process
对象列表的熟悉过程,每个对象都包含一个分叉的进程。这两个队列作为参数发送,并且 multiprocessing
处理它们的同步。启动了新进程后,我们将一系列作业交给 possible_primes_queue
,并以每个进程一个毒丸结束。作业将以 FIFO 顺序消耗,最后留下毒丸。在 check_prime
中,我们使用阻塞的 .get()
,因为新进程必须等待队列中出现工作。由于我们使用标志,我们可以添加一些工作,处理结果,然后通过稍后添加毒丸迭代添加更多工作,并通过稍后添加毒丸来标志工作人员的生命结束。
示例 9-10. 为 IPC 构建两个队列
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Project description")
parser.add_argument(
"nbr_workers", type=int, help="Number of workers e.g. 1, 2, 4, 8"
)
args = parser.parse_args()
primes = []
manager = multiprocessing.Manager()
possible_primes_queue = manager.Queue()
definite_primes_queue = manager.Queue()
pool = Pool(processes=args.nbr_workers)
processes = []
for _ in range(args.nbr_workers):
p = multiprocessing.Process(
target=check_prime, args=(possible_primes_queue,
definite_primes_queue)
)
processes.append(p)
p.start()
t1 = time.time()
number_range = range(100_000_000, 101_000_000)
# add jobs to the inbound work queue
for possible_prime in number_range:
possible_primes_queue.put(possible_prime)
# add poison pills to stop the remote workers
for n in range(args.nbr_workers):
possible_primes_queue.put(FLAG_ALL_DONE)
要消费结果,我们在 示例 9-11 中启动另一个无限循环,并在 definite_primes_queue
上使用阻塞的 .get()
。如果找到 finished-processing
标志,则计算已经信号退出的进程数。如果没有,则表示有一个新的质数,我们将其添加到 primes
列表中。当所有进程都已经信号退出时,我们退出无限循环。
示例 9-11. 使用两个队列进行 IPC
processors_indicating_they_have_finished = 0
while True:
new_result = definite_primes_queue.get() # block while waiting for results
if new_result == FLAG_WORKER_FINISHED_PROCESSING:
processors_indicating_they_have_finished += 1
if processors_indicating_they_have_finished == args.nbr_workers:
break
else:
primes.append(new_result)
assert processors_indicating_they_have_finished == args.nbr_workers
print("Took:", time.time() - t1)
print(len(primes), primes[:10], primes[-10:])
使用 Queue
存在相当大的开销,这是由于 pickling 和同步造成的。正如您在 图 9-14 中所见,使用 Queue
的单进程解决方案明显快于使用两个或更多进程。在本例中的原因是因为我们的工作负载非常轻——通信成本主导了此任务的整体时间。使用 Queue
,两个进程完成这个示例比一个进程稍快,而四个和八个进程则都较慢。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0914.png
图 9-14. 使用队列对象的成本
如果您的任务完成时间较长(至少占据几分之一秒),但通信量很少,则使用 Queue
方法可能是正确的选择。您需要验证通信成本是否足够使用此方法。
您可能会想知道如果我们移除作业队列的多余一半(所有偶数——在 check_prime
中这些会被非常快速地拒绝),会发生什么。减少输入队列的大小会减少每种情况下的执行时间,但仍然无法超过单进程非 Queue
示例!这有助于说明通信成本在此问题中是主导因素。
异步向队列添加作业
通过在主进程中添加一个Thread
,我们可以将作业异步地放入possible_primes_queue
中。在示例 9-12 中,我们定义了一个feed_new_jobs
函数:它执行与我们之前在__main__
中设置的作业设置例程相同的工作,但是它在一个单独的线程中执行。
示例 9-12. 异步作业供给函数
def feed_new_jobs(number_range, possible_primes_queue, nbr_poison_pills):
for possible_prime in number_range:
possible_primes_queue.put(possible_prime)
# add poison pills to stop the remote workers
for n in range(nbr_poison_pills):
possible_primes_queue.put(FLAG_ALL_DONE)
现在,在示例 9-13 中,我们的__main__
将使用possible_primes_queue
设置Thread
,然后继续到结果收集阶段之前发出任何工作。异步作业供给器可以从外部源(例如数据库或 I/O 限制通信)消耗工作,而__main__
线程则处理每个处理过的结果。这意味着输入序列和输出序列不需要预先创建;它们都可以即时处理。
示例 9-13. 使用线程设置异步作业供给器
if __name__ == "__main__":
primes = []
manager = multiprocessing.Manager()
possible_primes_queue = manager.Queue()
...
import threading
thrd = threading.Thread(target=feed_new_jobs,
args=(number_range,
possible_primes_queue,
NBR_PROCESSES))
thrd.start()
# deal with the results
如果你想要稳健的异步系统,几乎可以肯定要使用asyncio
或者像tornado
这样的外部库。关于这些方法的全面讨论,请查看第 8 章。我们在这里看到的例子可以帮助你入门,但实际上它们对于非常简单的系统和教育而言更有用,而不是用于生产系统。
要非常注意异步系统需要特别耐心——在调试时你可能会抓狂。我们建议如下操作:
-
应用“保持简单愚蠢”原则
-
如果可能,应避免使用异步自包含系统(如我们的示例),因为它们会变得越来越复杂并很快难以维护
-
使用像
gevent
这样的成熟库(在上一章中描述),这些库为处理某些问题集提供了经过验证的方法
此外,我们强烈建议使用提供队列状态外部可见性的外部队列系统(例如,在“NSQ 用于稳健的生产集群”中讨论的 NSQ、ZeroMQ 或 Celery)。这需要更多的思考,但可能会因提高调试效率和生产系统的更好可见性而节省您的时间。
提示
考虑使用任务图形以增强韧性。需要长时间运行队列的数据科学任务通常通过在无环图中指定工作流来有效服务。两个强大的库是Airflow和Luigi。这些在工业环境中广泛使用,支持任意任务链接、在线监控和灵活扩展。
使用进程间通信验证质数
质数是除了它们自己和 1 之外没有其他因子的数字。可以说最常见的因子是 2(每个偶数都不可能是质数)。之后,低质数(例如 3、5、7)成为较大的非质数(例如 9、15 和 21)的常见因子。
假设我们有一个大数,并被要求验证它是否是质数。我们可能会有一个大量的因子空间需要搜索。图 9-15 显示了非质数的每个因子在 10,000,000 以内的频率。低因子比高因子更有可能出现,但没有可预测的模式。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0915.png
图 9-15. 非质数因子的频率
让我们定义一个新问题——假设我们有一个小数字集,我们的任务是有效地使用 CPU 资源来逐个确定每个数字是否是质数。可能我们只有一个大数需要测试。现在不再有必要使用一个 CPU 来进行检查;我们希望跨多个 CPU 协调工作。
对于这一部分,我们将查看一些更大的数字,一个有 15 位数,四个有 18 位数:
-
小非质数:112,272,535,095,295
-
大非质数 1:100,109,100,129,100,369
-
大非质数 2:100,109,100,129,101,027
-
质数 1:100,109,100,129,100,151
-
质数 2:100,109,100,129,162,907
通过使用一个较小的非质数和一些较大的非质数,我们得以验证我们选择的处理过程不仅更快地检查质数,而且在检查非质数时也不会变慢。我们假设我们不知道被给定的数字的大小或类型,因此我们希望对所有用例都获得尽可能快的结果。
注意
如果您拥有这本书的旧版,您可能会惊讶地发现,使用 CPython 3.7 的这些运行时间稍慢于上一版中在较慢的笔记本电脑上运行的 CPython 2.7 的运行时间。这里的代码是一个特例,Python 3.x目前比 CPython 2.7 慢。这段代码依赖于整数操作;CPython 2.7 混合使用系统整数和“long”整数(可以存储任意大小的数字,但速度较慢)。CPython 3.x对所有操作只使用“long”整数。这个实现已经经过优化,但在某些情况下仍然比旧的(和更复杂的)实现慢。
我们从不必担心正在使用的“种类”整数,并且在 CPython 3.7 中,我们因此会稍微降低速度。这是一个微型基准测试,几乎不可能影响您自己的代码,因为 CPython 3.x在许多其他方面都比 CPython 2.x更快。我们的建议是不要担心这个问题,除非您大部分执行时间都依赖于整数操作——在这种情况下,我们强烈建议您查看 PyPy,它不会受到这种减速的影响。
合作是有代价的——同步数据和检查共享数据的成本可能会非常高。我们将在这里讨论几种可以用于任务协调的不同方法。
注意,我们在这里不涵盖有些专业的消息传递接口(MPI);我们关注的是一些内置的模块和 Redis(非常常见)。如果你想使用 MPI,我们假设你已经知道你在做什么。MPI4PY 项目可能是一个很好的起点。当许多进程协作时,如果你想控制延迟,无论是一台还是多台机器,它是一种理想的技术。
在以下运行中,每个测试重复进行 20 次,并取最小时间以显示可能的最快速度。在这些示例中,我们使用各种技术来共享一个标志(通常为 1 字节)。我们可以使用像Lock
这样的基本对象,但是这样只能共享 1 位状态。我们选择向您展示如何共享原始类型,以便进行更多表达式状态共享(即使对于此示例我们不需要更多表达式状态)。
我们必须强调,共享状态往往会使事情变得复杂——你很容易陷入另一种令人头疼的状态。要小心,并尽量保持事情尽可能简单。也许更有效的资源使用效果会被开发人员在其他挑战上的时间所超越。
首先我们将讨论结果,然后我们将详细阅读代码。
图 9-16 展示了尝试使用进程间通信更快地测试素性的初步方法。基准是串行版本,它不使用任何进程间通信;我们尝试加速代码的每一次尝试至少比这个版本更快。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0916.png
图 9-16. 用于验证素性的 IPC 较慢的方法
Less Naive Pool 版本具有可预测的(并且良好的)速度。它足够好,非常难以超越。在寻找高速解决方案时不要忽视显而易见的东西——有时一个愚蠢但足够好的解决方案就是你需要的。
Less Naive Pool 解决方案的方法是将我们要测试的数按可能的因子范围均匀分配给可用的 CPU,然后将工作分配给每个 CPU。如果任何 CPU 找到因子,它将提前退出,但它不会传达这一事实;其他 CPU 将继续处理它们范围内的工作。这意味着对于一个 18 位数(我们的四个较大示例),无论它是素数还是非素数,搜索时间都是相同的。
当测试大量因子以确定素性时,Redis 和Manager
解决方案由于通信开销而较慢。它们使用共享标志来指示是否已找到因子并且搜索应该停止。
Redis 让您不仅可以与其他 Python 进程共享状态,还可以与其他工具和其他计算机共享状态,甚至可以通过 Web 浏览器界面公开该状态(这对于远程监视可能很有用)。Manager
是 multiprocessing
的一部分;它提供了一组高级同步的 Python 对象(包括原语、list
和 dict
)。
对于更大的非素数情况,尽管检查共享标志会产生一些成本,但这个成本在早期发现因子并发出信号的搜索时间节省中微不足道。
对于素数情况,无法提前退出,因为不会找到任何因子,所以检查共享标志的成本将成为主导成本。
提示
一点思考往往就足够了。在这里,我们探讨了各种基于 IPC 的解决方案,以使素数验证任务更快。就“打字分钟”与“收益增加”而言,第一步——引入天真的并行处理——为我们带来了最大的收益,而付出的努力却最小。后续的收益需要进行大量额外的实验。始终考虑最终运行时间,特别是对于临时任务。有时,让一个循环运行整个周末来完成一个临时任务比优化代码以更快地运行更容易。
图 9-17 显示,通过一些努力,我们可以获得一个明显更快的结果。较不天真的 Pool 结果仍然是我们的基准线,但 RawValue
和 MMap(内存映射)的结果比以前的 Redis 和 Manager
结果快得多。真正的魔法来自于采取最快的解决方案,并执行一些不那么明显的代码操作,使得几乎最佳的 MMap 解决方案比 Less Naive Pool 解决方案在非素数情况下更快,并且在素数情况下几乎一样快。
在接下来的章节中,我们将通过各种方式来使用 Python 中的 IPC 来解决我们的协同搜索问题。我们希望您能看到 IPC 虽然相当容易,但通常会带来一些成本。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0917.png
图 9-17. 使用 IPC 进行验证素数的更快方法
串行解决方案
我们将从之前使用过的相同的串行因子检查代码开始,如 示例 9-14 中再次显示的那样。正如之前注意到的那样,对于任何具有较大因子的非素数,我们可以更有效地并行搜索因子空间。但是,串行扫描将为我们提供一个明智的基准线。
示例 9-14. 串行验证
def check_prime(n):
if n % 2 == 0:
return False
from_i = 3
to_i = math.sqrt(n) + 1
for i in range(from_i, int(to_i), 2):
if n % i == 0:
return False
return True
Naive Pool 解决方案
Naive Pool 解决方案使用了一个 multiprocessing.Pool
,类似于我们在 “寻找素数” 和 “使用进程和线程估算π” 中看到的,有四个 forked 进程。我们有一个要测试素数性的数字,我们将可能的因子范围分成了四个子范围的元组,并将这些发送到 Pool
中。
在示例 9-15 中,我们使用了一个新方法create_range.create
(我们不会展示它——它相当无聊),它将工作空间分割成大小相等的区域。ranges_to_check
中的每个项目是一对下限和上限,用于搜索之间。对于第一个 18 位数的非质数(100,109,100,129,100,369),使用四个进程,我们将得到因子范围 ranges_to_check == [(3, 79_100_057), (79_100_057, 158_200_111), (158_200_111, 237_300_165), (237_300_165, 316_400_222)]
(其中 316,400,222 是 100,109,100,129,100,369 的平方根加 1)。在__main__
中,我们首先建立一个Pool
;然后check_prime
通过map
方法将ranges_to_check
拆分为每个可能的质数n
。如果结果为False
,则找到了一个因子,这不是一个质数。
示例 9-15. 幼稚池解决方案
def check_prime(n, pool, nbr_processes):
from_i = 3
to_i = int(math.sqrt(n)) + 1
ranges_to_check = create_range.create(from_i, to_i, nbr_processes)
ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check)
assert len(ranges_to_check) == nbr_processes
results = pool.map(check_prime_in_range, ranges_to_check)
if False in results:
return False
return True
if __name__ == "__main__":
NBR_PROCESSES = 4
pool = Pool(processes=NBR_PROCESSES)
...
我们修改了前面示例 9-16 中的check_prime
,以获取要检查范围的下限和上限。传递完整的可能因子列表没有意义,因此通过传递仅定义我们范围的两个数字,我们节省了时间和内存。
示例 9-16. check_prime_in_range
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i)) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
for i in range(from_i, int(to_i), 2):
if n % i == 0:
return False
return True
对于“小非质数”情况,通过Pool
验证的时间为 0.1 秒,远远长于串行解决方案中的原始 0.000002 秒。尽管有一个更差的结果,整体结果是全面加速。也许我们可以接受一个较慢的结果不是问题——但如果我们可能有很多小非质数需要检查呢?事实证明我们可以避免这种减速;接下来我们将看到更为成熟的池解决方案。
更为成熟的池解决方案
先前的解决方案在验证较小的非质数时效率低下。对于任何较小(少于 18 位数)的非质数,由于发送分区工作的开销和不知道是否会找到一个非常小的因子(这是更有可能的因子),它可能比串行方法更慢。如果找到一个小因子,该过程仍然必须等待其他更大因子的搜索完成。
我们可以开始在进程之间发信号,表明已找到一个小因子,但由于这种情况非常频繁,这将增加大量的通信开销。示例 9-17 中提出的解决方案是一种更加实用的方法——快速执行串行检查以查找可能的小因子,如果没有找到,则启动并行搜索。在启动相对更昂贵的并行操作之前进行串行预检查是避免一些并行计算成本的常见方法。
示例 9-17. 改进小非质数情况下的幼稚池解决方案
def check_prime(n, pool, nbr_processes):
# cheaply check high-probability set of possible factors
from_i = 3
to_i = 21
if not check_prime_in_range((n, (from_i, to_i))):
return False
# continue to check for larger factors in parallel
from_i = to_i
to_i = int(math.sqrt(n)) + 1
ranges_to_check = create_range.create(from_i, to_i, nbr_processes)
ranges_to_check = zip(len(ranges_to_check) * [n], ranges_to_check)
assert len(ranges_to_check) == nbr_processes
results = pool.map(check_prime_in_range, ranges_to_check)
if False in results:
return False
return True
对于我们的每个测试数字,这种解决方案的速度要么相等,要么优于原始串行搜索。这是我们的新基准。
重要的是,这种 Pool
方法为素数检查提供了一个最佳案例。如果我们有一个素数,就没有办法提前退出;我们必须在退出之前手动检查所有可能的因子。
检查这些因素没有更快的方法:任何增加复杂性的方法都会有更多的指令,因此检查所有因素的情况将导致执行最多的指令。参见 “使用 mmap 作为标志” 中涵盖的各种 mmap
解决方案,讨论如何尽可能接近当前用于素数的结果。
使用 Manager.Value
作为标志
multiprocessing.Manager()
允许我们在进程之间共享更高级别的 Python 对象作为托管共享对象;较低级别的对象被包装在代理对象中。包装和安全性会增加速度成本,但也提供了极大的灵活性。可以共享较低级别的对象(例如整数和浮点数)以及列表和字典。
在 示例 9-18 中,我们创建了一个 Manager
,然后创建了一个 1 字节(字符)的 manager.Value(b"c", FLAG_CLEAR)
标志。如果需要共享字符串或数字,可以创建任何 ctypes
原语(与 array.array
原语相同)。
注意 FLAG_CLEAR
和 FLAG_SET
被分配了一个字节(b'0'
和 b'1'
)。我们选择使用前缀 b
来显式说明(如果不加 b
,可能会根据您的环境和 Python 版本默认为 Unicode 或字符串对象)。
现在我们可以在所有进程中传播一个因子已被发现的标志,因此可以提前结束搜索。难点在于平衡读取标志的成本与可能的速度节省。由于标志是同步的,我们不希望过于频繁地检查它 —— 这会增加更多的开销。
示例 9-18. 将 Manager.Value
对象作为标志传递
SERIAL_CHECK_CUTOFF = 21
CHECK_EVERY = 1000
FLAG_CLEAR = b'0'
FLAG_SET = b'1'
print("CHECK_EVERY", CHECK_EVERY)
if __name__ == "__main__":
NBR_PROCESSES = 4
manager = multiprocessing.Manager()
value = manager.Value(b'c', FLAG_CLEAR) # 1-byte character
...
check_prime_in_range
现在将意识到共享的标志,并且该例程将检查是否有其他进程发现了素数。即使我们尚未开始并行搜索,我们必须像 示例 9-19 中所示那样在开始串行检查之前清除标志。完成串行检查后,如果我们没有找到因子,我们知道标志必须仍然为假。
示例 9-19. 使用 Manager.Value
清除标志
def check_prime(n, pool, nbr_processes, value):
# cheaply check high-probability set of possible factors
from_i = 3
to_i = SERIAL_CHECK_CUTOFF
value.value = FLAG_CLEAR
if not check_prime_in_range((n, (from_i, to_i), value)):
return False
from_i = to_i
...
我们应该多频繁地检查共享标志?每次检查都有成本,因为我们将更多指令添加到紧密的内部循环中,并且检查需要对共享变量进行锁定,这会增加更多成本。我们选择的解决方案是每一千次迭代检查一次标志。每次检查时,我们都会查看value.value
是否已设置为FLAG_SET
,如果是,我们会退出搜索。如果在搜索中进程找到一个因子,则会将value.value = FLAG_SET
并退出(参见 示例 9-20)。
示例 9-20. 传递一个Manager.Value
对象作为标志
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i), value) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
check_every = CHECK_EVERY
for i in range(from_i, int(to_i), 2):
check_every -= 1
if not check_every:
if value.value == FLAG_SET:
return False
check_every = CHECK_EVERY
if n % i == 0:
value.value = FLAG_SET
return False
return True
这段代码中的千次迭代检查是使用check_every
本地计数器执行的。事实证明,尽管可读性强,但速度不佳。在本节结束时,我们将用一种可读性较差但显著更快的方法来替换它。
您可能会对我们检查共享标志的总次数感到好奇。对于两个大质数的情况,使用四个进程我们检查了 316,405 次标志(在所有后续示例中我们都会检查这么多次)。由于每次检查都因锁定而带来开销,这种成本真的会累积起来。
使用 Redis 作为标志
Redis 是一个键/值内存存储引擎。它提供了自己的锁定机制,每个操作都是原子的,因此我们无需担心从 Python(或任何其他接口语言)内部使用锁。
通过使用 Redis,我们使数据存储与语言无关—任何具有与 Redis 接口的语言或工具都可以以兼容的方式共享数据。您可以轻松在 Python、Ruby、C++和 PHP 之间共享数据。您可以在本地机器上或通过网络共享数据;要共享到其他机器,您只需更改 Redis 默认仅在localhost
上共享的设置。
Redis 允许您存储以下内容:
-
字符串的列表
-
字符串的集合
-
字符串的排序集合
-
字符串的哈希
Redis 将所有数据存储在 RAM 中,并进行快照到磁盘(可选使用日志记录),并支持主/从复制到一组实例的集群。Redis 的一个可能应用是将其用于在集群中分享工作负载,其中其他机器读取和写入状态,而 Redis 充当快速的集中式数据存储库。
我们可以像以前使用 Python 标志一样读取和写入文本字符串(Redis 中的所有值都是字符串)。我们创建一个StrictRedis
接口作为全局对象,它与外部 Redis 服务器通信。我们可以在check_prime_in_range
内部创建一个新连接,但这样做会更慢,并且可能耗尽可用的有限 Redis 句柄数量。
我们使用类似字典的访问方式与 Redis 服务器通信。我们可以使用rds[SOME_KEY] = SOME_VALUE
设置一个值,并使用rds[SOME_KEY]
读取字符串返回。
示例 9-21 与之前的Manager
示例非常相似——我们使用 Redis 替代了本地的Manager
。它具有类似的访问成本。需要注意的是,Redis 支持其他(更复杂的)数据结构;它是一个强大的存储引擎,我们仅在此示例中使用它来共享一个标志。我们鼓励您熟悉其特性。
示例 9-21. 使用外部 Redis 服务器作为我们的标志
FLAG_NAME = b'redis_primes_flag'
FLAG_CLEAR = b'0'
FLAG_SET = b'1'
rds = redis.StrictRedis()
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i)) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
check_every = CHECK_EVERY
for i in range(from_i, int(to_i), 2):
check_every -= 1
if not check_every:
flag = rds[FLAG_NAME]
if flag == FLAG_SET:
return False
check_every = CHECK_EVERY
if n % i == 0:
rds[FLAG_NAME] = FLAG_SET
return False
return True
def check_prime(n, pool, nbr_processes):
# cheaply check high-probability set of possible factors
from_i = 3
to_i = SERIAL_CHECK_CUTOFF
rds[FLAG_NAME] = FLAG_CLEAR
if not check_prime_in_range((n, (from_i, to_i))):
return False
...
if False in results:
return False
return True
要确认数据存储在这些 Python 实例之外,我们可以像在示例 9-22 中那样,在命令行上调用redis-cli
,并获取存储在键redis_primes_flag
中的值。您会注意到返回的项是一个字符串(而不是整数)。从 Redis 返回的所有值都是字符串,因此如果您想在 Python 中操作它们,您需要先将它们转换为适当的数据类型。
示例 9-22. redis-cli
$ redis-cli
redis 127.0.0.1:6379> GET "redis_primes_flag"
"0"
支持将 Redis 用于数据共享的一个强有力的论点是它存在于 Python 世界之外——您团队中不熟悉 Python 的开发人员也能理解它,并且存在许多针对它的工具。在阅读代码时,他们可以查看其状态,了解发生了什么(尽管不一定运行和调试)。从团队效率的角度来看,尽管使用 Redis 会增加沟通成本,但这可能对您来说是一个巨大的胜利。尽管 Redis 是项目中的额外依赖,但需要注意的是它是一个非常常见的部署工具,经过了良好的调试和理解。考虑将其作为增强您武器库的强大工具。
Redis 有许多配置选项。默认情况下,它使用 TCP 接口(这就是我们正在使用的),尽管基准文档指出套接字可能更快。它还指出,虽然 TCP/IP 允许您在不同类型的操作系统之间共享数据网络,但其他配置选项可能更快(但也可能限制您的通信选项):
当服务器和客户端基准程序在同一台计算机上运行时,可以同时使用 TCP/IP 回环和 Unix 域套接字。这取决于平台,但 Unix 域套接字在 Linux 上可以实现大约比 TCP/IP 回环高出 50%的吞吐量。redis-benchmark 的默认行为是使用 TCP/IP 回环。与 TCP/IP 回环相比,Unix 域套接字的性能优势在大量使用流水线时倾向于减少(即长流水线)。
Redis 在工业界广泛使用,成熟且信任。如果您对这个工具不熟悉,我们强烈建议您了解一下;它在您的高性能工具包中占据一席之地。
使用 RawValue 作为标志
multiprocessing.RawValue
是围绕 ctypes
字节块的薄包装。它缺乏同步原语,因此在我们寻找在进程之间设置标志位的最快方法时,几乎不会有阻碍。它几乎和下面的 mmap
示例一样快(它只慢了一点,因为多了几条指令)。
同样地,我们可以使用任何 ctypes
原始类型;还有一个 RawArray
选项用于共享一组原始对象(它们的行为类似于 array.array
)。RawValue
避免了任何锁定——使用起来更快,但你不能获得原子操作。
通常情况下,如果避免了 Python 在 IPC 期间提供的同步,你可能会遇到麻烦(再次回到那种让你抓狂的情况)。但是,在这个问题中,如果一个或多个进程同时设置标志位并不重要——标志位只会单向切换,并且每次读取时,只是用来判断是否可以终止搜索。
因为我们在并行搜索过程中从未重置标志位的状态,所以我们不需要同步。请注意,这可能不适用于你的问题。如果你避免同步,请确保你是出于正确的原因这样做。
如果你想做类似更新共享计数器的事情,请查看 Value
的文档,并使用带有 value.get_lock()
的上下文管理器,因为 Value
上的隐式锁定不允许原子操作。
这个示例看起来与之前的 Manager
示例非常相似。唯一的区别是在 示例 9-23 中,我们将 RawValue
创建为一个字符(字节)的标志位。
示例 9-23. 创建和传递 RawValue
if __name__ == "__main__":
NBR_PROCESSES = 4
value = multiprocessing.RawValue('b', FLAG_CLEAR) # 1-byte character
pool = Pool(processes=NBR_PROCESSES)
...
在 multiprocessing
中,使用受控和原始值的灵活性是数据共享清洁设计的一个优点。
使用 mmap 作为标志位
最后,我们来讨论最快的字节共享方式。示例 9-24 展示了使用 mmap
模块的内存映射(共享内存)解决方案。共享内存块中的字节不同步,并且带有非常少的开销。它们的行为类似于文件——在这种情况下,它们是一个带有文件接口的内存块。我们必须 seek
到某个位置,然后顺序读取或写入。通常情况下,mmap
用于在较大文件中创建一个短视图(内存映射),但在我们的情况下,作为第一个参数而不是指定文件号,我们传递 -1
表示我们想要一个匿名内存块。我们还可以指定我们是想要只读或只写访问(我们两者都要,这是默认值)。
示例 9-24. 使用 mmap
进行共享内存标志
sh_mem = mmap.mmap(-1, 1) # memory map 1 byte as a flag
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i)) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
check_every = CHECK_EVERY
for i in range(from_i, int(to_i), 2):
check_every -= 1
if not check_every:
sh_mem.seek(0)
flag = sh_mem.read_byte()
if flag == FLAG_SET:
return False
check_every = CHECK_EVERY
if n % i == 0:
sh_mem.seek(0)
sh_mem.write_byte(FLAG_SET)
return False
return True
def check_prime(n, pool, nbr_processes):
# cheaply check high-probability set of possible factors
from_i = 3
to_i = SERIAL_CHECK_CUTOFF
sh_mem.seek(0)
sh_mem.write_byte(FLAG_CLEAR)
if not check_prime_in_range((n, (from_i, to_i))):
return False
...
if False in results:
return False
return True
mmap
支持多种方法,可用于在其表示的文件中移动(包括find
、readline
和write
)。我们正在以最基本的方式使用它 —— 每次读取或写入前,我们都会seek
到内存块的开头,并且由于我们只共享 1 字节,所以我们使用read_byte
和write_byte
以明确方式。
没有 Python 锁定的开销,也没有数据的解释;我们直接与操作系统处理字节,因此这是我们最快的通信方法。
使用 mmap 作为旗帜的再现
尽管先前的mmap
结果在整体上表现最佳,但我们不禁要考虑是否能够回到最昂贵的素数案例的天真池结果。目标是接受内部循环没有早期退出,并尽量减少任何不必要的成本。
本节提出了一个稍微复杂的解决方案。虽然我们看到了基于其他基于标志的方法的相同变化,但这个mmap
结果仍然是最快的。
在我们之前的示例中,我们使用了CHECK_EVERY
。这意味着我们有check_next
本地变量来跟踪、递减和在布尔测试中使用 — 每个操作都会在每次迭代中增加一点额外的时间。在验证大素数的情况下,这种额外的管理开销发生了超过 300,000 次。
第一个优化,在示例 9-25 中展示,是意识到我们可以用一个预先查看的值替换递减的计数器,然后我们只需要在内循环中进行布尔比较。这样就可以去掉一个递减操作,因为 Python 的解释风格,这样做相当慢。这种优化在 CPython 3.7 中有效,但不太可能在更智能的编译器(如 PyPy 或 Cython)中带来任何好处。在检查我们的一个大素数时,这一步节省了 0.1 秒。
示例 9-25. 开始优化我们昂贵逻辑
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i)) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
check_next = from_i + CHECK_EVERY
for i in range(from_i, int(to_i), 2):
if check_next == i:
sh_mem.seek(0)
flag = sh_mem.read_byte()
if flag == FLAG_SET:
return False
check_next += CHECK_EVERY
if n % i == 0:
sh_mem.seek(0)
sh_mem.write_byte(FLAG_SET)
return False
return True
我们还可以完全替换计数器表示的逻辑,如示例 9-26 所示,将我们的循环展开为两个阶段的过程。首先,外循环按步长覆盖预期范围,但在CHECK_EVERY
上。其次,一个新的内循环替换了check_every
逻辑 —— 它检查因子的本地范围,然后完成。这等同于if not check_every:
测试。我们紧随其后的是先前的sh_mem
逻辑,以检查早期退出标志。
示例 9-26. 优化我们昂贵逻辑的方法
def check_prime_in_range(n_from_i_to_i):
(n, (from_i, to_i)) = n_from_i_to_i
if n % 2 == 0:
return False
assert from_i % 2 != 0
for outer_counter in range(from_i, int(to_i), CHECK_EVERY):
upper_bound = min(int(to_i), outer_counter + CHECK_EVERY)
for i in range(outer_counter, upper_bound, 2):
if n % i == 0:
sh_mem.seek(0)
sh_mem.write_byte(FLAG_SET)
return False
sh_mem.seek(0)
flag = sh_mem.read_byte()
if flag == FLAG_SET:
return False
return True
速度影响是显著的。即使是我们的非素数案例也进一步改进,但更重要的是,我们的素数检查案例几乎与较不天真的池版本一样快(现在只慢了 0.1 秒)。考虑到我们在进程间通信中做了大量额外的工作,这是一个有趣的结果。请注意,这只适用于 CPython,并且在编译器中运行时不太可能带来任何收益。
在书的最后一版中,我们通过一个最终示例进一步展示了循环展开和全局对象的局部引用,以牺牲可读性换取了更高的性能。在 Python 3 中,这个例子稍微慢了一点,所以我们将其删除了。我们对此感到高兴——为了得到最高性能的示例,不需要跳过太多障碍,前面的代码更可能在团队中得到正确支持,而不是进行特定于实现的代码更改。
提示
这些示例在 PyPy 中运行得非常好,比在 CPython 中快大约七倍。有时候,更好的解决方案是调查其他运行时,而不是在 CPython 的兔子洞里跳来跳去。
使用多处理共享 numpy 数据
当处理大型 numpy
数组时,你可能会想知道是否可以在进程之间共享数据以进行读写访问,而无需复制。虽然有点棘手,但是这是可能的。我们要感谢 Stack Overflow 用户 pv,他的灵感激发了这个演示。²
警告
不要使用此方法来重新创建 BLAS、MKL、Accelerate 和 ATLAS 的行为。这些库在它们的基本操作中都支持多线程,并且它们可能比你创建的任何新例程更经过充分调试。它们可能需要一些配置来启用多线程支持,但在你投入时间(和在调试中浪费的时间!)编写自己的代码之前,最好看看这些库是否可以为你提供免费的加速。
在进程之间共享大型矩阵有几个好处:
-
只有一个副本意味着没有浪费的 RAM。
-
没有浪费时间复制大块 RAM。
-
你可以在进程之间共享部分结果。
回想起在“使用 numpy”中使用 numpy
估算 pi 的演示,我们遇到的问题是随机数生成是一个串行进程。在这里,我们可以想象分叉进程,它们共享一个大数组,每个进程使用不同的种子随机数生成器填充数组的一部分,从而比单进程更快地生成一个大的随机块。
为了验证这一点,我们修改了即将展示的演示,创建了一个大型随机矩阵(10,000 × 320,000 元素)作为串行进程,并将矩阵分成四段,在并行调用 random
(在这两种情况下,每次一行)。串行进程花费了 53 秒,而并行版本只花了 29 秒。请参考“并行系统中的随机数”了解一些并行随机数生成的潜在风险。
在本节的其余部分,我们将使用一个简化的演示来说明这一点,同时保持易于验证。
在 图 9-18 中,您可以看到 Ian 笔记本电脑上 htop
的输出。显示父进程(PID 27628)的四个子进程,这五个进程共享一个 10,000 × 320,000 元素的 numpy
双精度数组。这个数组的一个副本占用了 25.6 GB,而笔记本只有 32 GB 内存 —— 您可以在 htop
中看到,进程仪表显示 Mem
读数最大为 31.1 GB RAM。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_0918.png
图 9-18. htop
显示 RAM 和交换使用情况
要理解这个演示,我们首先会浏览控制台输出,然后查看代码。在 示例 9-27 中,我们启动父进程:它分配了一个大小为 25.6 GB 的双精度数组,尺寸为 10,000 × 320,000,用值零填充。这 10,000 行将作为索引传递给工作函数,工作函数将依次操作每个 320,000 项的列。分配完数组后,我们将它填充为生命、宇宙和一切的答案 (42
!)。我们可以在工作函数中测试,我们收到的是修改后的数组,而不是填充为 0 的版本,以确认此代码的行为是否符合预期。
示例 9-27. 设置共享数组
$ python np_shared.py
Created shared array with 25,600,000,000 nbytes
Shared array id is 139636238840896 in PID 27628
Starting with an array of 0 values:
[[ 0\. 0\. 0\. ..., 0\. 0\. 0.]
...,
[ 0\. 0\. 0\. ..., 0\. 0\. 0.]]
Original array filled with value 42:
[[ 42\. 42\. 42\. ..., 42\. 42\. 42.]
...,
[ 42\. 42\. 42\. ..., 42\. 42\. 42.]]
Press a key to start workers using multiprocessing...
在 示例 9-28 中,我们启动了四个进程来处理这个共享数组。没有复制数组;每个进程都在查看相同的大内存块,并且每个进程有一组不同的索引来操作。每隔几千行,工作进程输出当前索引和其 PID,以便我们观察其行为。工作进程的工作是微不足道的 —— 它将检查当前元素是否仍设置为默认值(这样我们就知道没有其他进程已经修改它),然后将该值覆盖为当前 PID。一旦工作进程完成,我们返回到父进程并再次打印数组。这次,我们看到它填满了 PID,而不是 42
。
示例 9-28. 在共享数组上运行 worker_fn
worker_fn: with idx 0
id of local_nparray_in_process is 139636238840896 in PID 27751
worker_fn: with idx 2000
id of local_nparray_in_process is 139636238840896 in PID 27754
worker_fn: with idx 1000
id of local_nparray_in_process is 139636238840896 in PID 27752
worker_fn: with idx 4000
id of local_nparray_in_process is 139636238840896 in PID 27753
...
worker_fn: with idx 8000
id of local_nparray_in_process is 139636238840896 in PID 27752
The default value has been overwritten with worker_fn's result:
[[27751\. 27751\. 27751\. ... 27751\. 27751\. 27751.]
...
[27751\. 27751\. 27751\. ... 27751\. 27751\. 27751.]]
最后,在 示例 9-29 中,我们使用 Counter
来确认数组中每个 PID 的频率。由于工作被均匀分配,我们期望看到四个 PID 各自表示相等次数。在我们的 32 亿元素数组中,我们看到四组 8 亿次 PID。表格输出使用 PrettyTable 呈现。
示例 9-29. 验证共享数组的结果
Verification - extracting unique values from 3,200,000,000 items
in the numpy array (this might be slow)...
Unique values in main_nparray:
+---------+-----------+
| PID | Count |
+---------+-----------+
| 27751.0 | 800000000 |
| 27752.0 | 800000000 |
| 27753.0 | 800000000 |
| 27754.0 | 800000000 |
+---------+-----------+
Press a key to exit...
完成后,程序退出,数组被删除。
我们可以通过使用 ps
和 pmap
在 Linux 下查看每个进程的详细信息。示例 9-30 显示了调用 ps
的结果。分解这个命令行:
-
ps
告诉我们关于进程的信息。 -
-A
列出所有进程。 -
-o pid,size,vsize,cmd
输出 PID、大小信息和命令名称。 -
grep
用于过滤所有其他结果,仅保留演示的行。
父进程(PID 27628)及其四个分叉子进程显示在输出中。结果类似于我们在htop
中看到的。我们可以使用pmap
查看每个进程的内存映射,并使用-x
请求扩展输出。我们使用grep
筛选标记为共享的内存块的模式s-
。在父进程和子进程中,我们看到一个共享的 25,000,000 KB(25.6 GB)块。
示例 9-30。使用 pmap
和 ps
来调查操作系统对进程的视图
$ ps -A -o pid,size,vsize,cmd | grep np_shared
27628 279676 25539428 python np_shared.py
27751 279148 25342688 python np_shared.py
27752 279148 25342688 python np_shared.py
27753 279148 25342688 python np_shared.py
27754 279148 25342688 python np_shared.py
ian@ian-Latitude-E6420 $ pmap -x 27628 | grep s-
Address Kbytes RSS Dirty Mode Mapping
00007ef9a2853000 25000000 25000000 2584636 rw-s- pym-27628-npfjsxl6 (deleted)
...
ian@ian-Latitude-E6420 $ pmap -x 27751 | grep s-
Address Kbytes RSS Dirty Mode Mapping
00007ef9a2853000 25000000 6250104 1562508 rw-s- pym-27628-npfjsxl6 (deleted)
...
我们将使用 multiprocessing.Array
来分配一个共享的内存块作为一个 1D 数组,然后从这个对象实例化一个 numpy
数组并将其重塑为一个 2D 数组。现在我们有一个可以在进程之间共享并像普通 numpy
数组一样访问的 numpy
包装的内存块。numpy
不管理 RAM;multiprocessing.Array
在管理它。
在 示例 9-31 中,您可以看到每个分叉进程都可以访问全局的main_nparray
。虽然分叉的进程拥有numpy
对象的副本,但对象访问的底层字节存储为共享内存。我们的worker_fn
将使用当前进程标识符覆盖选择的行(通过idx
)。
示例 9-31。使用 multiprocessing
共享 numpy
数组的 worker_fn
import os
import multiprocessing
from collections import Counter
import ctypes
import numpy as np
from prettytable import PrettyTable
SIZE_A, SIZE_B = 10_000, 320_000 # 24GB
def worker_fn(idx):
"""Do some work on the shared np array on row idx"""
# confirm that no other process has modified this value already
assert main_nparray[idx, 0] == DEFAULT_VALUE
# inside the subprocess print the PID and ID of the array
# to check we don't have a copy
if idx % 1000 == 0:
print(" {}: with idx {}\n id of local_nparray_in_process is {} in PID {}"\
.format(worker_fn.__name__, idx, id(main_nparray), os.getpid()))
# we can do any work on the array; here we set every item in this row to
# have the value of the process ID for this process
main_nparray[idx, :] = os.getpid()
在我们的 __main__
中 示例 9-32,我们将通过三个主要阶段来进行工作:
-
构建一个共享的
multiprocessing.Array
并将其转换为一个numpy
数组。 -
将默认值设置到数组中,并生成四个进程以并行处理数组。
-
在进程返回后验证数组的内容。
通常,您会设置一个numpy
数组并在单个进程中处理它,可能会执行类似于arr = np.array((100, 5), dtype=np.float_)
的操作。在单个进程中这样做没问题,但是您无法将这些数据跨进程进行读写共享。
制作共享字节块的技巧之一是创建multiprocessing.Array
。默认情况下,Array
被包装在一个锁中以防止并发编辑,但我们不需要这个锁,因为我们将小心处理我们的访问模式。为了清楚地向其他团队成员传达这一点,明确设置lock=False
是值得的。
如果不设置 lock=False
,您将得到一个对象而不是字节的引用,您需要调用 .get_obj()
来获取字节。通过调用 .get_obj()
,您绕过了锁,因此在一开始明确这一点是非常重要的。
接下来,我们将这一块可共享的字节块使用 frombuffer
包装成一个 numpy
数组。dtype
是可选的,但由于我们传递的是字节,显式指定类型总是明智的。我们使用 reshape
来将字节地址化为二维数组。默认情况下,数组的值被设置为 0
。示例 9-32 显示了我们的 __main__
完整内容。
示例 9-32. __main__
用于设置 numpy
数组以供共享
if __name__ == '__main__':
DEFAULT_VALUE = 42
NBR_OF_PROCESSES = 4
# create a block of bytes, reshape into a local numpy array
NBR_ITEMS_IN_ARRAY = SIZE_A * SIZE_B
shared_array_base = multiprocessing.Array(ctypes.c_double,
NBR_ITEMS_IN_ARRAY, lock=False)
main_nparray = np.frombuffer(shared_array_base, dtype=ctypes.c_double)
main_nparray = main_nparray.reshape(SIZE_A, SIZE_B)
# assert no copy was made
assert main_nparray.base.base is shared_array_base
print("Created shared array with {:,} nbytes".format(main_nparray.nbytes))
print("Shared array id is {} in PID {}".format(id(main_nparray), os.getpid()))
print("Starting with an array of 0 values:")
print(main_nparray)
print()
为了确认我们的进程是否在我们开始时的同一块数据上运行,我们将每个项目设置为一个新的 DEFAULT_VALUE
(我们再次使用 42
,生命、宇宙和一切的答案)。您可以在 示例 9-33 的顶部看到。接下来,我们构建了一个进程池(这里是四个进程),然后通过调用 map
发送批次的行索引。
示例 9-33. 使用 multiprocessing
共享 numpy
数组的 __main__
# Modify the data via our local numpy array
main_nparray.fill(DEFAULT_VALUE)
print("Original array filled with value {}:".format(DEFAULT_VALUE))
print(main_nparray)
input("Press a key to start workers using multiprocessing...")
print()
# create a pool of processes that will share the memory block
# of the global numpy array, share the reference to the underlying
# block of data so we can build a numpy array wrapper in the new processes
pool = multiprocessing.Pool(processes=NBR_OF_PROCESSES)
# perform a map where each row index is passed as a parameter to the
# worker_fn
pool.map(worker_fn, range(SIZE_A))
当并行处理完成后,我们返回到父进程验证结果(示例 9-34)。验证步骤通过数组的展平视图来执行(请注意,视图不会进行复制;它只是在二维数组上创建一个一维可迭代视图),计算每个进程 ID 的频率。最后,我们执行一些 assert
检查以确保得到预期的计数。
示例 9-34. 用于验证共享结果的 __main__
print("Verification - extracting unique values from {:,} items\n in the numpy \
array (this might be slow)...".format(NBR_ITEMS_IN_ARRAY))
# main_nparray.flat iterates over the contents of the array, it doesn't
# make a copy
counter = Counter(main_nparray.flat)
print("Unique values in main_nparray:")
tbl = PrettyTable(["PID", "Count"])
for pid, count in list(counter.items()):
tbl.add_row([pid, count])
print(tbl)
total_items_set_in_array = sum(counter.values())
# check that we have set every item in the array away from DEFAULT_VALUE
assert DEFAULT_VALUE not in list(counter.keys())
# check that we have accounted for every item in the array
assert total_items_set_in_array == NBR_ITEMS_IN_ARRAY
# check that we have NBR_OF_PROCESSES of unique keys to confirm that every
# process did some of the work
assert len(counter) == NBR_OF_PROCESSES
input("Press a key to exit...")
刚刚我们创建了一个字节的一维数组,将其转换为二维数组,共享该数组给四个进程,并允许它们同时处理同一块内存区域。这个技巧将帮助您在许多核心上实现并行化。不过要注意并发访问相同的数据点——如果想避免同步问题,就必须使用 multiprocessing
中的锁,但这会减慢您的代码执行速度。
同步文件和变量访问
在接下来的示例中,我们将看到多个进程共享和操作状态——在这种情况下,四个进程递增一个共享计数器一定次数。如果没有同步过程,计数将不正确。如果您要以一致的方式共享数据,您总是需要一种同步读写数据的方法,否则会出现错误。
通常,同步方法特定于您使用的操作系统,并且通常特定于您使用的语言。在这里,我们使用 Python 库来进行基于文件的同步,并在 Python 进程之间共享整数对象。
文件锁定
在本节中,文件读写将是数据共享中最慢的例子。
您可以在 示例 9-35 中看到我们的第一个 work
函数。该函数迭代一个本地计数器。在每次迭代中,它打开一个文件并读取现有值,将其增加一,然后将新值写入旧值所在的位置。在第一次迭代中,文件将为空或不存在,因此它将捕获异常并假定该值应为零。
提示
这里给出的示例是简化的—在实践中,更安全的做法是使用上下文管理器打开文件,例如 with open(*filename*, "r") as f:
。如果上下文中引发异常,文件 f
将被正确关闭。
示例 9-35. 没有锁定的 work
函数
def work(filename, max_count):
for n in range(max_count):
f = open(filename, "r")
try:
nbr = int(f.read())
except ValueError as err:
print("File is empty, starting to count from 0, error: " + str(err))
nbr = 0
f = open(filename, "w")
f.write(str(nbr + 1) + '\n')
f.close()
让我们使用一个进程运行这个示例。您可以在 示例 9-36 中看到输出。work
被调用一千次,预期的是它能正确计数而不丢失任何数据。在第一次读取时,它看到一个空文件。这导致了 int()
的 invalid literal for int()
错误(因为在空字符串上调用了 int()
)。这个错误只会发生一次;之后我们总是有一个有效的值来读取并转换为整数。
示例 9-36. 在没有锁定并且使用一个进程的文件计数的时间安排
$ python ex1_nolock1.py
Starting 1 process(es) to count to 1000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 1000
count.txt contains:
1000
现在我们将使用四个并发进程运行相同的 work
函数。我们没有任何锁定代码,因此我们预计会得到一些奇怪的结果。
提示
在您查看以下代码之前,请思考当两个进程同时从同一文件读取或写入时,您可以期望看到什么 两种 类型的错误?考虑代码的两个主要状态(每个进程的执行开始和每个进程的正常运行状态)。
看一下 示例 9-37 以查看问题。首先,当每个进程启动时,文件为空,因此每个进程都试图从零开始计数。其次,当一个进程写入时,另一个进程可以读取部分写入的结果,无法解析。这会导致异常,并且将回写一个零。这反过来导致我们的计数器不断被重置!您能看到两个并发进程写入了 \n
和两个值到同一个打开的文件,导致第三个进程读取到一个无效的条目吗?
示例 9-37. 在没有锁定并且使用四个进程的文件计数的时间安排
$ python ex1_nolock4.py
Starting 4 process(es) to count to 4000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
*# many errors like these*
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 4000
count.txt contains:
112
$ python -m timeit -s "import ex1_nolock4" "ex1_nolock4.run_workers()"
2 loops, best of 5: 158 msec per loop
示例 9-38 展示了调用带有四个进程的 work
的 multiprocessing
代码。请注意,我们不是使用 map
,而是在建立 Process
对象的列表。虽然在此处我们没有使用这个功能,但 Process
对象使我们能够审视每个 Process
的状态。我们鼓励您 阅读文档 了解为什么您可能希望使用 Process
。
示例 9-38. run_workers
设置四个进程
import multiprocessing
import os
...
MAX_COUNT_PER_PROCESS = 1000
FILENAME = "count.txt"
...
def run_workers():
NBR_PROCESSES = 4
total_expected_count = NBR_PROCESSES * MAX_COUNT_PER_PROCESS
print("Starting {} process(es) to count to {}".format(NBR_PROCESSES,
total_expected_count))
# reset counter
f = open(FILENAME, "w")
f.close()
processes = []
for process_nbr in range(NBR_PROCESSES):
p = multiprocessing.Process(target=work, args=(FILENAME,
MAX_COUNT_PER_PROCESS))
p.start()
processes.append(p)
for p in processes:
p.join()
print("Expecting to see a count of {}".format(total_expected_count))
print("{} contains:".format(FILENAME))
os.system('more ' + FILENAME)
if __name__ == "__main__":
run_workers()
使用fasteners
模块,我们可以引入一种同步方法,这样一次只有一个进程可以写入,其他进程则各自等待它们的轮次。因此整个过程运行速度较慢,但不会出错。您可以在示例 9-39 中看到正确的输出。请注意,锁定机制特定于 Python,因此查看此文件的其他进程将不会关心此文件的“锁定”性质。
示例 9-39. 带锁和四个进程的文件计数时间
$ python ex1_lock.py
Starting 4 process(es) to count to 4000
File is empty, starting to count from 0,
error: invalid literal for int() with base 10: ''
Expecting to see a count of 4000
count.txt contains:
4000
$ python -m timeit -s "import ex1_lock" "ex1_lock.run_workers()"
10 loops, best of 3: 401 msec per loop
使用fasteners
在示例 9-40 中加入了一行代码,使用@fasteners.interprocess_locked
装饰器;文件名可以是任何东西,但最好与您想要锁定的文件名称相似,这样在命令行中进行调试会更容易。请注意,我们没有必要更改内部函数;装饰器在每次调用时获取锁,并且在进入work
之前会等待能够获取锁。
示例 9-40. 带锁的work
函数
@fasteners.interprocess_locked('/tmp/tmp_lock')
def work(filename, max_count):
for n in range(max_count):
f = open(filename, "r")
try:
nbr = int(f.read())
except ValueError as err:
print("File is empty, starting to count from 0, error: " + str(err))
nbr = 0
f = open(filename, "w")
f.write(str(nbr + 1) + '\n')
f.close()
锁定一个值
multiprocessing
模块为在进程之间共享 Python 对象提供了几种选项。我们可以使用低通信开销共享原始对象,还可以使用Manager
共享更高级的 Python 对象(例如字典和列表),但请注意同步成本会显著减慢数据共享的速度。
在这里,我们将使用一个multiprocessing.Value
对象来在进程之间共享整数。虽然Value
有一个锁,但锁并不完全符合您的期望——它防止同时读取或写入,但不提供原子增量。示例 9-41 说明了这一点。您可以看到我们最终得到了一个不正确的计数;这与我们之前查看的基于文件的未同步示例类似。
示例 9-41. 无锁导致计数不正确
$ python ex2_nolock.py
Expecting to see a count of 4000
We have counted to 2340
$ python -m timeit -s "import ex2_nolock" "ex2_nolock.run_workers()"
20 loops, best of 5: 9.97 msec per loop
数据未损坏,但我们错过了一些更新。如果您从一个进程向Value
写入并在其他进程中消耗(但不修改)该Value
,这种方法可能适合。
共享Value
的代码显示在示例 9-42 中。我们必须指定数据类型和初始化值——使用Value("i", 0)
,我们请求一个带有默认值0
的有符号整数。这作为常规参数传递给我们的Process
对象,后者在幕后负责在进程之间共享同一块字节。要访问Value
持有的原始对象,我们使用.value
。请注意,我们要求原地添加——我们期望这是一个原子操作,但Value
不支持这一点,因此我们的最终计数低于预期。
示例 9-42. 没有Lock
的计数代码
import multiprocessing
def work(value, max_count):
for n in range(max_count):
value.value += 1
def run_workers():
...
value = multiprocessing.Value('i', 0)
for process_nbr in range(NBR_PROCESSES):
p = multiprocessing.Process(target=work, args=(value, MAX_COUNT_PER_PROCESS))
p.start()
processes.append(p)
...
你可以在示例 9-43 中看到使用multiprocessing.Lock
的正确同步计数。
示例 9-43. 使用Lock
同步对Value
的写入
*`# lock on the update, but this isn't atomic`*
$ `python` `ex2_lock``.``py`
Expecting to see a count of 4000
We have counted to 4000
$ `python` `-``m` `timeit` `-``s` `"``import ex2_lock``"` `"``ex2_lock.run_workers()``"`
20 loops, best of 5: 19.3 msec per loop
在示例 9-44 中,我们使用了上下文管理器(with Lock
)来获取锁定。
示例 9-44. 使用上下文管理器获取Lock
import multiprocessing
def work(value, max_count, lock):
for n in range(max_count):
with lock:
value.value += 1
def run_workers():
...
processes = []
lock = multiprocessing.Lock()
value = multiprocessing.Value('i', 0)
for process_nbr in range(NBR_PROCESSES):
p = multiprocessing.Process(target=work,
args=(value, MAX_COUNT_PER_PROCESS, lock))
p.start()
processes.append(p)
...
如果我们避免使用上下文管理器,直接用acquire
和release
包装我们的增量,我们可以稍微快一点,但与使用上下文管理器相比,代码可读性较差。我们建议坚持使用上下文管理器以提高可读性。示例 9-45 中的片段显示了如何acquire
和release
Lock
对象。
示例 9-45. 内联锁定而不使用上下文管理器
lock.acquire()
value.value += 1
lock.release()
由于Lock
无法提供我们所需的粒度级别,它提供的基本锁定会不必要地浪费一些时间。我们可以将Value
替换为RawValue
,如示例 9-46 中所示,并实现渐进式加速。如果你对查看这种更改背后的字节码感兴趣,请阅读Eli Bendersky 的博客文章。
示例 9-46. 显示更快RawValue
和Lock
方法的控制台输出
*`# RawValue has no lock on it`*
$ `python` `ex2_lock_rawvalue``.``py`
Expecting to see a count of 4000
We have counted to 4000
$ `python` `-``m` `timeit` `-``s` `"``import ex2_lock_rawvalue``"` `"``ex2_lock_rawvalue.run_workers()``"`
50 loops, best of 5: 9.49 msec per loop
要使用RawValue
,只需将其替换为Value
,如示例 9-47 所示。
示例 9-47. 使用RawValue
整数的示例
...
def run_workers():
...
lock = multiprocessing.Lock()
value = multiprocessing.RawValue('i', 0)
for process_nbr in range(NBR_PROCESSES):
p = multiprocessing.Process(target=work,
args=(value, MAX_COUNT_PER_PROCESS, lock))
p.start()
processes.append(p)
如果我们共享的是原始对象数组,则可以使用RawArray
代替multiprocessing.Array
。
我们已经看过了在单台机器上将工作分配给多个进程的各种方式,以及在这些进程之间共享标志和同步数据共享的方法。但请记住,数据共享可能会带来麻烦—尽量避免。让一台机器处理所有状态共享的边缘情况是困难的;当你第一次必须调试多个进程的交互时,你会意识到为什么公认的智慧是尽可能避免这种情况。
考虑编写运行速度稍慢但更容易被团队理解的代码。使用像 Redis 这样的外部工具来共享状态,可以使系统在运行时由开发人员之外的其他人检查—这是一种强大的方式,可以使你的团队了解并掌控并行系统的运行情况。
一定要记住,经过调整的高性能 Python 代码不太可能被团队中较新手的成员理解—他们要么会对此感到恐惧,要么会破坏它。为了保持团队的速度,避免这个问题(并接受速度上的牺牲)。
总结
在本章中,我们涵盖了很多内容。首先,我们看了两个尴尬的并行问题,一个具有可预测的复杂性,另一个具有不可预测的复杂性。当我们讨论聚类时,我们将在多台机器上再次使用这些示例。
接下来,我们看了看multiprocessing
中的Queue
支持及其开销。总的来说,我们建议使用外部队列库,以便队列的状态更透明。最好使用易于阅读的作业格式,以便易于调试,而不是使用 pickled 数据。
IPC 讨论应该让您了解到使用 IPC 有效地有多困难,以及仅仅使用天真的并行解决方案(没有 IPC)可能是有道理的。购买一台更快的具有更多核心的计算机可能比尝试使用 IPC 来利用现有机器更为务实。
在并行情况下共享numpy
矩阵而不复制对于只有少数一些问题非常重要,但在关键时刻,它将真正重要。这需要写入一些额外的代码,并需要一些合理性检查,以确保在进程之间确实没有复制数据。
最后,我们讨论了使用文件和内存锁来避免数据损坏的问题——这是一个难以察觉和难以追踪错误的来源,本节向您展示了一些强大且轻量级的解决方案。
在下一章中,我们将讨论使用 Python 进行聚类。通过使用集群,我们可以摆脱单机并行性,并利用一组机器上的 CPU。这引入了一种新的调试痛苦的世界——不仅您的代码可能存在错误,而且其他机器也可能存在错误(无论是由于错误的配置还是由于硬件故障)。我们将展示如何使用 Parallel Python 模块并如何在 IPython 中运行研究代码,以在 IPython 集群中并行化 pi 估算演示。
¹ 参见Brett Foster 的 PowerPoint 演示,讲解如何使用蒙特卡洛方法估算 pi。
² 请参阅 Stack Overflow 主题。
第十章:集群和作业队列
集群通常被认为是一组共同工作以解决共同任务的计算机的集合。从外部看,它可以被视为一个更大的单一系统。
在上世纪九十年代,使用本地区域网络上的廉价个人电脑集群进行集群处理的概念,即被称为贝奥武夫集群,变得流行起来。后来,Google通过在自己的数据中心使用廉价个人电脑集群,特别是用于运行 MapReduce 任务,进一步推动了这一实践。在另一个极端,TOP500 项目每年排名最强大的计算机系统;这些系统通常采用集群设计,而最快的机器都使用 Linux。
Amazon Web Services(AWS)通常用于在云中构建工程生产集群以及为机器学习等短期任务构建按需集群。通过 AWS,您可以租用从微型到大型机器,拥有 10 个 CPU 和高达 768 GB 的 RAM,每小时租金为 1 到 15 美元。可以额外支付租用多个 GPU。如果您想要探索 AWS 或其他提供商在计算密集或内存密集任务上的临时集群,可以查看“使用 IPython 并行支持研究”和 ElastiCluster 包。
不同的计算任务在集群中需要不同的配置、大小和功能。我们将在本章中定义一些常见场景。
在您转移到集群解决方案之前,请确保您已经完成了以下工作:
-
分析您的系统,以了解瓶颈
-
利用像 Numba 和 Cython 这样的编译器解决方案
-
利用单台机器上的多个核心(可能是一台具有多个核心的大型机器),使用 Joblib 或
multiprocessing
-
利用技术来减少 RAM 使用
将系统保持在一台机器上可以让您的生活更加轻松(即使这台“一台机器”是一台配置非常强大、内存和 CPU 都很多的计算机)。如果您确实需要大量的 CPU 或者能够并行处理数据从磁盘读取的能力,或者您有高可靠性和快速响应的生产需求,请迁移到一个集群。大多数研究场景不需要弹性或可扩展性,并且仅限于少数人,因此通常最简单的解决方案是最明智的选择。
留在一个大机器上的好处是像 Dask 这样的工具可以快速并行化您的 Pandas 或纯 Python 代码,而无需网络复杂性。Dask 还可以控制一组机器以并行化处理 Pandas、NumPy 和纯 Python 问题。Swifter 通过在 Dask 上共享负载,自动并行化一些多核单机案例。我们稍后在本章介绍 Dask 和 Swifter 两者。
集群的好处
集群最明显的好处是您可以轻松扩展计算需求 — 如果您需要处理更多数据或更快地获得答案,只需添加更多机器(或节点)。
通过添加机器,您还可以提高可靠性。每台机器的组件有一定的故障可能性,但是通过良好的设计,多个组件的故障不会停止集群的运行。
集群还用于创建动态扩展的系统。一个常见的用例是将一组处理网络请求或相关数据的服务器集群化(例如,调整用户照片大小、转码视频或转录语音),并在一天中某些时间段内随着需求增加而激活更多的服务器。
动态扩展是处理非均匀使用模式的一种非常经济高效的方式,只要机器激活时间足够快以应对需求变化的速度。
提示
考虑建立集群的投入与回报。虽然集群的并行化增益可能会令人心动,但请考虑与构建和维护集群相关的成本。它们非常适合生产环境中长时间运行的流程或明确定义且经常重复的研发任务。对于变量和短期的研发任务,它们则不那么吸引人。
集群的一个微妙的好处是,集群可以在地理上分开但仍然集中控制。如果一个地理区域遭受故障(例如洪水或停电),另一个集群可以继续工作,也许会增加更多处理单元来处理需求。集群还允许您运行异构软件环境(例如不同版本的操作系统和处理软件),这可能会提高整个系统的鲁棒性——但请注意,这绝对是一个高级话题!
集群的缺点
迁移到集群解决方案需要改变思维方式。这是从串行代码到并行代码所需的思维变革的进化,就像我们在第九章中介绍的那样。突然间,你不得不考虑当你有多台机器时会发生什么——你有机器之间的延迟,你需要知道你的其他机器是否在工作,并且你需要保持所有机器运行相同版本的软件。系统管理可能是你面临的最大挑战。
此外,通常你必须深思熟虑正在实施的算法,以及一旦所有这些额外的运动部分可能需要保持同步时会发生什么。这种额外的规划可能会带来沉重的心理负担;它很可能会让你分心,一旦系统变得足够大,你可能需要增加一个专门的工程师到你的团队中。
注意
在本书中,我们尝试专注于有效使用一台计算机,因为我们认为只处理一台计算机比处理集合更容易(尽管我们承认玩集群可能会更有趣——直到它出问题为止)。如果您可以垂直扩展(购买更多的 RAM 或更多的 CPU),则值得调查这种方法是否优于集群。当然,您的处理需求可能超出了垂直扩展的可能性,或者集群的稳健性可能比拥有单一机器更重要。然而,如果您是一个单独的人在处理这个任务,也要记住运行一个集群将会占用您的一些时间。
当设计集群解决方案时,您需要记住每台机器的配置可能不同(每台机器的负载和本地数据都不同)。您如何将所有正确的数据传送到正在处理作业的机器上?将作业和数据移动涉及的延迟是否会成为问题?您的作业是否需要相互通信部分结果?如果一个进程失败或者一台机器故障,或者一些硬件在多个作业运行时自行清除,会发生什么情况?如果您不考虑这些问题,可能会引入故障。
您还应考虑到故障可以被接受的可能性。例如,当您运行基于内容的网络服务时,可能不需要 99.999%的可靠性——如果偶尔作业失败(例如,图片无法及时调整大小)并要求用户重新加载页面,那是每个人都已经习惯了的事情。这可能不是您想要给用户的解决方案,但通常接受一点失败可以显著降低工程和管理成本。另一方面,如果高频交易系统出现故障,糟糕的股市交易成本可能是可观的!
维护固定基础设施可能会变得昂贵。购买机器相对便宜,但它们有一个令人头痛的毛病——自动软件升级可能会出现故障,网络卡故障,磁盘写入错误,电源供应器可能提供干扰数据的尖峰电源,宇宙射线可能会在 RAM 模块中翻转位。您拥有的计算机越多,处理这些问题所需的时间就会越多。迟早您会想要引入一个能够处理这些问题的系统工程师,所以预算中要再增加$100,000。使用基于云的集群可以减少许多这些问题(成本更高,但无需处理硬件维护),一些云服务提供商还提供按需市场定价,用于获取便宜但临时的计算资源。
随着时间的推移,有机生长的集群中存在的一个阴险问题是,如果一切都关闭了,可能没有人记录如何安全地重新启动它。如果没有记录的重新启动计划,你应该假设在最糟糕的时候将不得不编写一个(我们的一位作者曾在圣诞前夕处理这种问题 —— 这可不是你想要的圣诞礼物!)。此时,你还将了解系统中每个部分启动需要多长时间 —— 每个集群部分可能需要几分钟来启动和处理作业,因此如果有 10 个部分依次操作,整个系统从冷启动到运行可能需要一个小时。其结果是你可能有一个小时的积压数据。那么你是否有必要的容量及时处理这些积压数据呢?
懈怠的行为可能导致昂贵的错误,复杂和难以预料的行为可能导致意外和昂贵的后果。让我们看看两个高调的集群故障,并学习其中的教训。
通过糟糕的集群升级策略导致的 462 亿美元华尔街损失
2012 年,高频交易公司 Knight Capital 在集群软件升级期间引入错误后,损失了 4.62 亿美元。软件下达了比客户要求的更多的股票订单。
在交易软件中,一个旧的标志被重新用于新的功能。升级已经在八台实时机器中的七台上推出,但第八台机器使用旧代码处理标志,导致交易错误。证券交易委员会(SEC)指出,Knight Capital 没有让第二位技术人员审查该升级,实际上也没有建立审查此类升级的流程。
这个根本性错误似乎有两个原因。第一个原因是软件开发过程没有移除一个已过时的特性,因此陈旧的代码仍然存在。第二个原因是没有设置手动审查流程来确认升级是否成功完成。
技术债务会增加一笔成本,最终必须付清 —— 最好在无压力的时候花时间清除债务。无论是构建还是重构代码,始终使用单元测试。在系统升级过程中缺乏书面检查清单和第二双眼睛,可能会导致昂贵的失败。飞行员在起飞前要按照清单逐项检查是有原因的:这意味着无论之前做过多少次,都不会漏掉重要步骤!
Skype 全球 24 小时服务中断
Skype 在 2010 年经历了24 小时全球范围内的故障。在幕后,Skype 由对等网络支持。系统的某一部分(用于处理离线即时消息)过载导致 Windows 客户端的响应延迟;某些版本的 Windows 客户端未能正确处理延迟响应而崩溃。总体而言,大约 40%的活跃客户端崩溃,包括 25%的公共超级节点。超级节点对网络中的数据路由至关重要。
路由的 25%离线(它后来恢复了,但速度很慢),整个网络处于极大的压力之下。崩溃的 Windows 客户端节点也在重新启动并尝试重新加入网络,给已经过载的系统增加了新的流量。如果超级节点承受过多负载,它们会采取后退程序,因此它们开始响应波浪式流量而关闭。
Skype 在整整 24 小时内大部分时间都无法使用。恢复过程首先涉及设置数百个新的“超级节点”,配置以处理增加的流量,然后继续设置数千个节点。在接下来的几天里,网络逐渐恢复正常。
这一事件给 Skype 带来了很多尴尬;显然,它也改变了焦点,几天内主要集中在损害控制上。客户被迫寻找语音通话的替代解决方案,这可能成为竞争对手的市场优势。
鉴于网络的复杂性和失败的升级,这次故障很可能难以预测和计划。网络上没有所有节点失败的原因是软件的不同版本和不同平台——拥有异构网络而不是同构系统有可靠性的好处。
常见的集群设计
常见的做法是从一个局域的临时集群开始,使用相对等价的机器。你可能会想知道是否可以将旧计算机添加到临时网络中,但通常旧的 CPU 消耗大量电力并且运行非常慢,因此与一台新的高规格机器相比,它们贡献的远不如你希望的那么多。办公室内的集群需要有人来维护。在Amazon 的 EC2或Microsoft 的 Azure,或者由学术机构运行的集群,硬件支持交给了服务提供商的团队。
如果你已经理解了处理需求,设计一个定制集群可能是明智的选择——也许使用 InfiniBand 高速互联代替千兆以太网,或者使用支持你的读写或容错要求的特定配置 RAID 驱动器。你可能希望在一些机器上结合 CPU 和 GPU,或者只是默认使用 CPU。
您可能需要一个像SETI@home和Folding@home项目使用的大规模分散处理集群,通过伯克利开放网络计算基础设施(BOINC)系统共享集中协调系统,但计算节点以临时方式加入和退出项目。
在硬件设计之上,您可以运行不同的软件架构。工作队列是最常见且最容易理解的。通常,作业被放入队列并由处理器消耗。处理的结果可能会进入另一个队列进行进一步处理,或者作为最终结果使用(例如,添加到数据库中)。消息传递系统略有不同——消息被放入消息总线,然后被其他机器消耗。消息可能会超时并被删除,并且可能会被多个机器消耗。在更复杂的系统中,进程通过进程间通信相互交流——这可以被认为是专家级别的配置,因为有很多方法可以设置得很糟糕,这将导致您失去理智。只有当您确实知道需要它时,才可以选择使用 IPC 路线。
如何启动一个集群解决方案
启动集群系统的最简单方法是从一个机器开始,该机器将同时运行作业服务器和作业处理器(每个 CPU 只有一个作业处理器)。如果您的任务是 CPU 绑定的,请为每个 CPU 运行一个作业处理器;如果任务是 I/O 绑定的,请为每个 CPU 运行多个作业处理器。如果它们受 RAM 限制,请小心不要用完 RAM。让您的单机解决方案使用一个处理器运行,并逐步增加更多处理器。通过不可预测的方式使您的代码失败(例如,在您的代码中执行1/0
,在您的工作进程上使用kill -9 <pid>
,从插座上拔下电源插头,使整个机器死机),以检查您的系统是否健壮。
显然,您需要进行比这更严格的测试——一个充满编码错误和人为异常的单元测试套件很好。Ian 喜欢引入意外事件,例如让一个处理器运行一组作业,同时一个外部进程正在系统地终止重要进程,并确认所有这些进程都能被使用的监控进程干净地重新启动。
一旦您有一个正在运行的作业处理器,添加第二个。检查您是否没有使用太多 RAM。您处理作业的速度是否比以前快两倍?
现在引入第二台机器,该新机器上只有一个作业处理器,并且协调机器上没有作业处理器。它处理作业的速度是否与您在协调机器上有处理器时一样快?如果不是,为什么?延迟是否是问题?您是否有不同的配置?也许您有不同的机器硬件,如 CPU、RAM 和缓存大小?
现在再添加另外九台计算机,并测试看看你是否比以前处理作业快 10 倍。如果没有,为什么?是不是现在出现了网络冲突,导致整体处理速度变慢?
为了在机器启动时可靠地启动集群的组件,我们倾向于使用cron
任务,Circus,或者supervisord
。Circus 和supervisord
都是基于 Python 并且已经存在多年。cron
虽然老旧,但如果你只是启动像监控进程这样的脚本,它非常可靠,可以根据需要启动子进程。
一旦你拥有一个可靠的集群,你可能想引入像 Netflix 的Chaos Monkey这样的随机杀手工具,它故意杀死系统的一部分来测试其弹性。你的进程和硬件最终会失败,了解你可能至少能够幸存你预测可能发生的错误,这不会伤害。
在使用集群时避免痛苦的方法
在伊恩经历的一个特别痛苦的经历中,集群系统中一系列队列停顿了。后续队列未被消费,因此它们堆积起来。一些机器的 RAM 用尽,导致它们的进程死亡。先前的队列正在处理,但无法将结果传递给下一个队列,因此它们崩溃了。最终,第一个队列被填充但未被消费,因此它崩溃了。之后,我们为供应商的数据付费,最终被丢弃。你必须勾勒一些注意事项,考虑你的集群可能会死亡的各种方式,以及发生时会发生什么(不是如果)。你会丢失数据(这是一个问题吗)?你会有一个太痛苦无法处理的大后台任务吗?
拥有一个易于调试的系统可能比拥有一个更快的系统更重要。工程时间和停机成本可能是你最大的开支(如果你在运行导弹防御程序,这就不是真的,但对于初创公司来说可能是真的)。与其通过使用低级压缩的二进制协议来节省几个字节,不如在传递消息时考虑使用 JSON 中的人类可读文本。这确实会增加发送和解码消息的开销,但当核心计算机着火后,你留下的部分数据库能够快速阅读重要消息时,你会庆幸能够迅速将系统恢复在线。
确保在时间和金钱上廉价地部署系统更新——无论是操作系统更新还是软件的新版本。每当集群中的任何变化发生时,如果处于分裂状态,系统就有可能以奇怪的方式响应。确保使用像Fabric,Salt,Chef或Puppet这样的部署系统,或者像 Debian 的*.deb*,RedHat 的*.rpm*,或Amazon Machine Image这样的系统映像。能够强大地部署一个更新并升级整个集群(并报告任何发现的问题)大大减轻了在困难时期的压力。
积极的报告很有用。每天给某人发送一封电子邮件,详细说明集群的性能。如果这封电子邮件没有送达,那是某事发生的有用线索。你可能还希望有其他更快通知你的早期警报系统;Pingdom和Server Density在这方面尤为有用。一个反应于事件缺失的“死人开关”(例如,Dead Man’s Switch)是另一个有用的备份。
向团队报告集群健康情况非常有用。这可能是一个 Web 应用程序内的管理页面,或者一个单独的报告。Ganglia在这方面非常棒。Ian 看到过一个类似星际迷航 LCARS 界面的界面在办公室的一个备用 PC 上运行,当检测到问题时会播放“红色警报”声音——这对引起整个办公室的注意特别有效。我们甚至看到 Arduinos 驱动像老式锅炉压力表这样的模拟仪器(当指针移动时发出漂亮的声音!)显示系统负载。这种报告非常重要,以便每个人都明白“正常”和“可能会毁了我们周五晚上”的区别。
两种聚类解决方案
在本节中,我们介绍 IPython Parallel 和 NSQ。
IPython 集群在一台具有多个核心的机器上易于使用。由于许多研究人员将 IPython 作为他们的 shell 或通过 Jupyter Notebooks 工作,自然也会用它来进行并行作业控制。构建一个集群需要一些系统管理知识。使用 IPython Parallel 的一个巨大优势是,你可以像使用本地集群一样轻松地使用远程集群(例如亚马逊的 AWS 和 EC2)。
NSQ 是一个成熟的队列系统。它具有持久性(因此如果机器死机,作业可以由另一台机器继续进行)和强大的可伸缩机制。随着这种更强大的功能,对系统管理和工程技能的需求稍微增加。然而,NSQ 在其简单性和易用性方面表现出色。虽然存在许多排队系统(如流行的Kafka),但没有一个像 NSQ 那样具有如此低的准入门槛。
使用 IPython Parallel 支持研究
IPython 集群支持通过 IPython 并行 项目实现。IPython 成为本地和远程处理引擎的接口,数据可以在引擎之间传递,作业可以推送到远程机器。远程调试是可能的,消息传递接口(MPI)也是可选支持的。这种相同的 ZeroMQ 通信机制支持 Jupyter Notebook 接口。
这对于研究环境非常有用——您可以将作业推送到本地集群中的机器,如果有问题,可以进行交互式调试,将数据推送到机器上,并收集结果,所有这些都是交互式的。请注意,PyPy 运行 IPython 和 IPython 并行。结合起来可能非常强大(如果您不使用 numpy
)。
在幕后,ZeroMQ 被用作消息中间件——请注意,ZeroMQ 的设计不提供安全性。如果您在本地网络上构建一个集群,可以避免 SSH 认证。如果您需要安全性,SSH 完全支持,但它使配置变得有点复杂——从一个本地可信网络开始,并随着学习每个组件的工作方式而逐步构建。
该项目分为四个组件。引擎是 IPython 内核的扩展;它是一个同步的 Python 解释器,用于运行你的代码。您将运行一组引擎以启用并行计算。控制器提供了一个与引擎的接口;它负责工作分配并提供了一个直接接口和一个负载平衡接口,该接口提供了一个工作调度器。中心跟踪引擎、调度器和客户端。调度器隐藏了引擎的同步性质并提供了一个异步接口。
在笔记本电脑上,我们使用 ipcluster start -n 4
启动了四个引擎。在 示例 10-1 中,我们启动了 IPython 并检查本地 Client
是否能够看到我们的四个本地引擎。我们可以使用 c[:]
来访问所有四个引擎,并且我们将一个函数应用到每个引擎上——apply_sync
接受一个可调用对象,因此我们提供了一个不带参数的lambda
,它将返回一个字符串。我们的四个本地引擎中的每一个都会运行这些函数,返回相同的结果。
示例 10-1. 测试我们是否能够在 IPython 中看到本地引擎
In [1]: import ipyparallel as ipp
In [2]: c = ipp.Client()
In [3]: print(c.ids)
[0, 1, 2, 3]
In [4]: c[:].apply_sync(lambda: "Hello High Performance Pythonistas!")
Out[4]:
['Hello High Performance Pythonistas!',
'Hello High Performance Pythonistas!',
'Hello High Performance Pythonistas!',
'Hello High Performance Pythonistas!']
我们构建的引擎现在处于空状态。如果我们在本地导入模块,它们不会被导入到远程引擎中。
一种干净的方式来进行本地和远程导入是使用 sync_imports
上下文管理器。在 示例 10-2 中,我们将在本地 IPython 和四个连接的引擎上都import os
,然后再次在这四个引擎上调用apply_sync
来获取它们的 PID。
如果我们没有进行远程导入,我们将会得到一个 NameError
,因为远程引擎不会知道 os
模块。我们也可以使用 execute
在引擎上远程运行任何 Python 命令。
示例 10-2. 将模块导入到我们的远程引擎中
In [5]: dview=c[:] # this is a direct view (not a load-balanced view)
In [6]: with dview.sync_imports():
....: import os
....:
importing os on engine(s)
In [7]: dview.apply_sync(lambda:os.getpid())
Out[7]: [16158, 16159, 16160, 16163]
In [8]: dview.execute("import sys") # another way to execute commands remotely
您将希望将数据推送到引擎。在 示例 10-3 中展示的 push
命令允许您发送一个字典项,这些项会添加到每个引擎的全局命名空间中。有一个对应的 pull
命令用于检索项目:您给它键,它会返回每个引擎对应的值。
示例 10-3. 将共享数据推送到引擎
In [9]: dview.push({'shared_data':[50, 100]})
Out[9]: <AsyncResult: _push>
In [10]: dview.apply_sync(lambda:len(shared_data))
Out[10]: [2, 2, 2, 2]
在 示例 10-4 中,我们使用这四个引擎来估算圆周率。这次我们使用 @require
装饰器在引擎中导入 random
模块。我们使用直接视图将工作发送到引擎;这会阻塞直到所有结果返回。然后我们像 示例 9-1 中那样估算圆周率。
示例 10-4. 使用我们的本地集群估算圆周率
import time
import ipyparallel as ipp
from ipyparallel import require
@require('random')
def estimate_nbr_points_in_quarter_circle(nbr_estimates):
...
return nbr_trials_in_quarter_unit_circle
if __name__ == "__main__":
c = ipp.Client()
nbr_engines = len(c.ids)
print("We're using {} engines".format(nbr_engines))
nbr_samples_in_total = 1e8
nbr_parallel_blocks = 4
dview = c[:]
nbr_samples_per_worker = nbr_samples_in_total / nbr_parallel_blocks
t1 = time.time()
nbr_in_quarter_unit_circles = \
dview.apply_sync(estimate_nbr_points_in_quarter_circle,
nbr_samples_per_worker)
print("Estimates made:", nbr_in_quarter_unit_circles)
nbr_jobs = len(nbr_in_quarter_unit_circles)
pi_estimate = sum(nbr_in_quarter_unit_circles) * 4 / nbr_samples_in_total
print("Estimated pi", pi_estimate)
print("Delta:", time.time() - t1)
在 示例 10-5 中,我们在我们的四个本地引擎上运行这个。如同 图 9-5 中所示,这在笔记本电脑上大约需要 20 秒。
示例 10-5. 在 IPython 中使用我们的本地集群估算圆周率
In [1]: %run pi_ipython_cluster.py
We're using 4 engines
Estimates made: [19636752, 19634225, 19635101, 19638841]
Estimated pi 3.14179676
Delta: 20.68650197982788
IPython Parallel 提供的远不止这里展示的功能。当然,还支持异步作业和在更大输入范围上的映射。支持 MPI,可以提供高效的数据共享。在 “用 Joblib 替换多处理” 中介绍的 Joblib 库可以与 IPython Parallel 一起作为后端使用,以及 Dask(我们在 “使用 Dask 进行并行 Pandas” 中介绍)。
IPython Parallel 的一个特别强大的功能是允许您使用更大的集群环境,包括超级计算机和云服务,如亚马逊的 EC2. ElastiCluster 项目 支持常见的并行环境,如 IPython,以及包括 AWS、Azure 和 OpenStack 在内的部署目标。
使用 Dask 进行并行 Pandas
Dask 的目标是提供一套从笔记本上的单个核心到多核机器再到集群中数千个核心的并行化解决方案。把它想象成“Apache Spark 的精简版”。如果您不需要 Apache Spark 的所有功能(包括复制写入和多机故障转移),并且不想支持第二个计算和存储环境,那么 Dask 可能提供您所需要的并行化和大于内存解决方案。
为了延迟评估多种计算场景,包括纯 Python、科学 Python 和使用小、中、大数据集的机器学习,构建了一个任务图:
Bag
bag
可以对非结构化和半结构化数据进行并行计算,包括文本文件、JSON 或用户定义的对象。支持对通用 Python 对象进行 map
、filter
和 groupby
操作,包括列表和集合。
数组
array
能够进行分布式和大于 RAM 的 numpy
操作。支持许多常见操作,包括一些线性代数函数。不支持跨核心效率低下的操作(例如排序和许多线性代数操作)。使用线程,因为 NumPy 具有良好的线程支持,所以在并行化操作期间无需复制数据。
分布式数据框
dataframe
能够进行分布式和大于 RAM 的 Pandas
操作;在幕后,Pandas 用于表示使用其索引分区的部分数据框。操作使用 .compute()
惰性计算,并且在其他方面与其 Pandas 对应物非常相似。支持的函数包括 groupby-aggregate
、groupby-apply
、value_counts
、drop_duplicates
和 merge
。默认情况下使用线程,但由于 Pandas 比 NumPy 更受 GIL 限制,您可能需要查看进程或分布式调度器选项。
Delayed
delayed
扩展了我们在 “Replacing multiprocessing with Joblib” 中介绍的与 Joblib 类似的思想,以惰性方式并行化任意 Python 函数链。visualize()
函数将绘制任务图来帮助诊断问题。
Futures
Client
接口支持即时执行和任务演变,与 delayed
不同,后者是惰性的,不允许像添加或销毁任务这样的操作。Future
接口包括 Queue
和 Lock
,以支持任务协作。
Dask-ML
提供了类似于 scikit-learn 的接口以进行可扩展的机器学习。Dask-ML 为一些 scikit-learn 算法提供了集群支持,并且使用 Dask 重新实现了一些算法(例如 linear_model
集)以便在大数据上进行学习。它缩小了与 Apache Spark 分布式机器学习工具包之间的差距。还提供了支持 XGBoost 和 TensorFlow 在 Dask 集群中使用的功能。
对于 Pandas 用户,Dask 可以帮助解决两个用例:大于 RAM 的数据集和多核并行化的需求。
如果你的数据集比 Pandas 能够装入 RAM 的还要大,Dask 可以将数据集按行分割成一组分区数据框,称为 分布式数据框。这些数据框按其索引分割;可以在每个分区上执行一部分操作。例如,如果你有一组多 GB 的 CSV 文件,并且想要在所有文件上计算 value_counts
,Dask 将在每个数据框(每个文件一个)上执行部分 value_counts
,然后将结果合并为单一的计数集。
第二个用例是利用笔记本电脑上的多个核心(以及同样容易地在集群中使用);我们将在这里研究这个用例。回想一下,在 Example 6-24 中,我们用不同的方法计算了数据框中值行的线斜率。让我们使用两种最快的方法,并使用 Dask 进行并行化。
提示
您可以使用 Dask(以及下一节讨论的 Swifter)并行化任何无副作用的函数,通常在apply
调用中使用。Ian 已经为大型 DataFrame 中的数字计算和计算文本列的多个文本度量执行了此操作。
使用 Dask 时,我们必须指定要从 DataFrame 中创建的分区数量;一个经验法则是至少使用与核心数相同的分区,以便每个核心都可以被使用。在 Example 10-6 中,我们请求了八个分区。我们使用dd.from_pandas
将常规的 Pandas DataFrame 转换为一个 Dask 分布式 DataFrame,分为八个大小相等的部分。
我们在分布式 DataFrame 上调用熟悉的ddf.apply
,指定我们的函数ols_lstsq
和通过meta
参数指定的可选的预期返回类型。Dask 要求我们使用compute()
调用指定计算应该何时应用;在这里,我们指定使用processes
而不是默认的threads
来将工作分布到多个核心上,避免 Python 的 GIL。
Example 10-6. 使用 Dask 在多个核心上计算线斜率
import dask.dataframe as dd
N_PARTITIONS = 8
ddf = dd.from_pandas(df, npartitions=N_PARTITIONS, sort=False)
SCHEDULER = "processes"
results = ddf.apply(ols_lstsq, axis=1, meta=(None, 'float64',)). \
compute(scheduler=SCHEDULER)
在 Ian 的笔记本电脑上,使用相同的八个分区(在四个核心和四个超线程的条件下)运行ols_lstsq_raw
,从之前单线程的apply
结果的 6.8 秒提高到 1.5 秒,速度几乎提升了 5 倍。
Example 10-7. 使用 Dask 在多个核心上计算线斜率
results = ddf.apply(ols_lstsq_raw, axis=1, meta=(None, 'float64',), raw=True). \
compute(scheduler=SCHEDULER)
使用相同的八个分区运行ols_lstsq_raw
,将我们从之前使用raw=True
单线程apply
结果的 5.3 秒提高到 1.2 秒,速度几乎提升了 5 倍。
如果我们还使用从“Numba to Compile NumPy for Pandas”中编译的 Numba 函数并使用raw=True
,我们的运行时间从 0.58 秒降低到 0.3 秒,进一步提速了 2 倍。使用 Numba 在 Pandas DataFrame 上使用 NumPy 数组编译的函数非常适合与 Dask 配合使用,而且付出的努力很少。
在 Dask 上使用 Swifter 进行并行应用
Swifter基于 Dask 提供了三个并行选项,只需简单的调用—apply
、resample
和rolling
。在幕后,它会对 DataFrame 的子样本进行采样,并尝试向量化函数调用。如果成功,Swifter 将应用它;如果成功但速度慢,Swifter 将使用 Dask 在多个核心上运行它。
由于 Swifter 使用启发式方法确定如何运行您的代码,因此它可能比根本不使用它运行得慢,但尝试的“成本”仅为一行努力。评估它是非常值得的。
Swifter 根据 Dask 决定使用多少个核心以及对其评估进行采样的行数;因此,在 Example 10-8 中,我们看到对df.swifter...apply()
的调用看起来就像对df.apply
的常规调用。在这种情况下,我们已禁用进度条;在使用优秀的tqdm
库的 Jupyter Notebook 中,进度条可以正常工作。
示例 10-8. 使用 Dask 使用多核计算线斜率
import swifter
results = df.swifter.progress_bar(False).apply(ols_lstsq_raw, axis=1, raw=True)
使用 ols_lstsq_raw
和没有分区选择的 Swifter,将我们之前的单线程结果从 5.3 秒降低到 1.6 秒。对于这个特定的函数和数据集,这并不像我们刚刚看过的稍长的 Dask 解决方案那样快,但它确实只用了一行代码就提供了 3 倍的加速。对于不同的函数和数据集,你将会看到不同的结果;进行实验看看是否可以获得非常容易的成功。
用于大于 RAM 的 DataFrame 的 Vaex
Vaex 是一个令人感兴趣的新库,提供了类似于 Pandas DataFrame 的结构,内置支持大于 RAM 的计算。它将 Pandas 和 Dask 的功能整合到一个单独的包中。
Vaex 使用惰性计算来按需计算列结果;它将仅计算用户需要的行子集。例如,如果你要求在两列之间的十亿行上进行求和,并且你只要求结果的样本,Vaex 将仅触及那个样本的数据,不会计算所有未被抽样行的总和。对于交互式工作和基于可视化的探索,这可能非常高效。
Pandas 对字符串的支持来自于 CPython;它受到 GIL 的限制,并且字符串对象是散布在内存中的较大对象,不支持矢量化操作。Vaex 使用自己的自定义字符串库,这使得基于字符串的操作速度显著提高,并具有类似 Pandas 的界面。
如果你正在处理字符串密集的 DataFrame 或大于 RAM 的数据集,Vaex 是一个显而易见的评估选择。如果你通常在 DataFrame 的子集上工作,隐式的惰性评估可能会使你的工作流程比将 Dask 添加到 Pandas DataFrame 更简单。
用于稳健生产聚类的 NSQ
在生产环境中,你将需要比我们到目前为止谈论过的其他解决方案更健壮的解决方案。这是因为在集群的日常运行过程中,节点可能变得不可用,代码可能崩溃,网络可能中断,或者其他可能发生的成千上万的问题可能发生。问题在于,所有先前的系统都有一个发出命令的计算机,以及一定数量的读取命令并执行它们的计算机。相反,我们希望有一个可以通过消息总线进行多个参与者通信的系统——这将允许我们有任意数量且不断变化的消息创建者和消费者。
一个解决这些问题的简单方法是NSQ,一个高性能的分布式消息平台。尽管它是用 GO 语言编写的,但完全是数据格式和语言无关的。因此,有许多语言的库,并且进入 NSQ 的基本接口是一个只需能够进行 HTTP 调用的 REST API。此外,我们可以以任何格式发送消息:JSON,Pickle,msgpack
等等。然而最重要的是,它提供了关于消息传递的基本保证,并且所有这些都是使用两种简单的设计模式完成的:队列和发布/订阅。
注意
我们选择 NSQ 来讨论,因为它易于使用并且通常表现良好。对于我们的目的而言,最重要的是,它清楚地突显了在考虑在集群中排队和传递消息时必须考虑的因素。然而,其他解决方案如 ZeroMQ,Amazon 的 SQS,Celery,甚至 Redis 可能更适合您的应用程序。
队列
队列 是一种消息的缓冲区。每当您想将消息发送到处理管道的另一部分时,您将其发送到队列中,并且它将在那里等待,直到有可用的工作进程。当生产和消费之间存在不平衡时,队列在分布式处理中最为有用。当出现这种不平衡时,我们可以简单地通过增加更多的数据消费者来进行水平扩展,直到消息的生产速率和消费速率相等。此外,如果负责消费消息的计算机出现故障,则消息不会丢失,而是简单地排队,直到有消费者可用,从而为我们提供消息传递的保证。
例如,假设我们希望每当用户在我们的网站上对新项目进行评分时处理新的推荐。如果没有队列,"rate"操作将直接调用"recalculate-recommendations"操作,而不管处理推荐的服务器有多忙。如果突然间有成千上万的用户决定对某物品进行评分,我们的推荐服务器可能会被请求淹没,它们可能会开始超时,丢失消息,并且通常变得无响应!
另一方面,有了队列,推荐服务器在准备好时会请求更多的任务。新的"rate"操作会将新任务放入队列中,当推荐服务器准备好执行更多工作时,它将从队列中获取任务并处理它。在这种设置中,如果比正常情况下更多的用户开始对项目进行评分,我们的队列会填满并充当推荐服务器的缓冲区——它们的工作负载不会受影响,它们仍然可以处理消息,直到队列为空。
这种方式的一个潜在问题是,如果一个队列被工作完全压倒,它将会存储大量消息。NSQ 通过具有多个存储后端来解决这个问题 —— 当消息不多时,它们存储在内存中,随着更多消息的到来,消息被放置到磁盘上。
注意
一般来说,在处理排队系统时,最好尝试使下游系统(例如上面示例中的推荐系统)在正常工作负载下处于 60%的容量。这是在为问题分配过多资源和确保服务器有足够的额外能力以处理超出正常工作量的情况之间的一个很好的折衷方案。
发布/订阅
另一方面,发布/订阅(简称发布者/订阅者)描述了谁会接收到什么消息。数据发布者可以从特定主题推送数据,数据订阅者可以订阅不同的数据源。每当发布者发布一条信息时,它被发送给所有订阅者 —— 每个订阅者都会得到原始信息的相同副本。你可以把它想象成报纸:很多人可以订阅同一份报纸,每当新版报纸出来时,每个订阅者都会得到相同的副本。此外,报纸的生产者不需要知道它的报纸被发送给了所有订阅者。因此,发布者和订阅者在系统中是解耦的,这使得我们的系统在网络变化时仍然可以保持更加健壮的生产状态。
此外,NSQ 还引入了数据消费者的概念;也就是说,可以将多个进程连接到同一个数据订阅中。每当新的数据出现时,每个订阅者都会收到数据的一份副本;然而,每个订阅的消费者只会看到这些数据中的一部分。在报纸的类比中,可以将其想象为同一户中有多个人在读同一份报纸。出版者会将一份报纸送到这户人家,因为这户只有一个订阅,那么谁先看到就可以阅读该数据。每个订阅的消费者在看到消息时会进行相同的处理;然而,它们可能位于多台计算机上,从而为整个池增加了更多的处理能力。
我们可以在图 10-1 中看到这种发布/订阅/消费者范式的描绘。如果在“clicks”主题上发布了新消息,所有订阅者(或者在 NSQ 术语中,通道 —例如,“metrics”,“spam_analysis”,和“archive”)将会收到一份副本。每个订阅者由一个或多个消费者组成,代表实际处理消息的进程。在“metrics”订阅者的情况下,只有一个消费者会看到新消息。接下来的消息将传递给另一个消费者,依此类推。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_1001.png
图 10-1. NSQ 的发布/订阅式拓扑结构
将消息分散到可能的大量消费者之间的好处在于自动负载均衡。如果一个消息需要很长时间来处理,那么该消费者在完成之前不会向 NSQ 发出已准备好接收更多消息的信号,因此其他消费者将获得未来大部分的消息(直到原始消费者再次准备好处理)。此外,它允许现有的消费者断开连接(无论是自愿还是由于故障),并允许新的消费者连接到集群,同时仍然在特定订阅组内保持处理能力。例如,如果我们发现“指标”需要相当长的时间来处理,并且通常无法满足需求,我们可以简单地为该订阅组的消费者池添加更多进程,从而为我们提供更多的处理能力。另一方面,如果我们看到大多数进程处于空闲状态(即,没有收到任何消息),我们可以轻松地从此订阅池中删除消费者。
还需注意的是,任何东西都可以发布数据。消费者不仅仅需要是消费者,它可以从一个主题消费数据,然后将其发布到另一个主题。实际上,这种链条在涉及分布式计算范式时是一个重要的工作流程。消费者将从一个数据主题中读取数据,以某种方式转换数据,然后将数据发布到其他消费者可以进一步转换的新主题上。通过这种方式,不同的主题代表不同的数据,订阅组代表对数据的不同转换,而消费者则是实际转换个别消息的工作者。
此外,该系统提供了令人难以置信的冗余性。每个消费者连接的可能有许多 nsqd
进程,并且可能有许多消费者连接到特定的订阅。这样,即使出现多台机器消失,也不会存在单点故障,您的系统将是健壮的。我们可以在图 10-2 中看到,即使图中的计算机之一宕机,系统仍能够交付和处理消息。此外,由于 NSQ 在关闭时将待处理消息保存到磁盘上,除非硬件丢失是灾难性的,否则您的数据很可能仍然完好无损并得到交付。最后,如果消费者在回复特定消息之前关闭,NSQ 将将该消息重新发送给另一个消费者。这意味着即使消费者被关闭,我们也知道所有主题中的所有消息至少会被响应一次。¹
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/hpp2_1002.png
图 10-2. NSQ 连接拓扑
分布式素数计算
使用 NSQ 的代码通常是异步的(详见 Chapter 8 进行完整的解释),尽管并不一定必须是。² 在下面的例子中,我们将创建一个工作池,从一个名为 numbers 的主题中读取消息,这些消息只是简单的包含数字的 JSON。消费者将读取此主题,查找这些数字是否为质数,然后根据数字是否为质数将其写入另一个主题。这将给我们带来两个新主题,prime 和 non_prime,其他消费者可以连接到这些主题以进行更多计算。³
注意
pynsq
(最后发布于 2018 年 11 月 11 日)依赖于一个非常过时的 tornado
版本(4.5.3,于 2018 年 1 月 6 日发布)。这是 Docker 的一个很好的使用案例(在 “Docker” 中讨论)。
如前所述,像这样进行 CPU 绑定工作有很多好处。首先,我们拥有所有健壮性的保证,这对这个项目可能有用,也可能没用。然而更重要的是,我们获得了自动负载平衡。这意味着,如果一个消费者得到一个需要很长时间来处理的数字,其他消费者会填补空缺。
我们创建一个消费者,通过指定主题和订阅组来创建一个 nsq.Reader
对象(如在 Example 10-9 的最后部分可以看到)。我们还必须指定运行中的 nsqd
实例的位置(或者 nsqlookupd
实例,在本节中我们不会深入讨论)。此外,我们还必须指定一个 handler,这只是一个用于处理从主题接收到的每条消息的函数。为了创建一个生产者,我们创建一个 nsq.Writer
对象,并指定一个或多个要写入的 nsqd
实例的位置。这使我们能够通过指定主题名称和消息来写入到 nsq
。
示例 10-9. 使用 NSQ 进行分布式质数计算
import json
from functools import partial
from math import sqrt
import nsq
def is_prime(number):
if number % 2 == 0:
return False
for i in range(3, int(sqrt(number)) + 1, 2):
if number % i == 0:
return False
return True
def write_message(topic, data, writer):
response = writer.pub(topic, data)
if isinstance(response, nsq.Error):
print("Error with Message: {}: {}".format(data, response))
return write_message(data, writer)
else:
print("Published Message: ", data)
def calculate_prime(message, writer):
data = json.loads(message.body)
prime = is_prime(data["number"])
data["prime"] = prime
if prime:
topic = "prime"
else:
topic = "non_prime"
output_message = json.dumps(data).encode("utf8")
write_message(topic, output_message, writer)
message.finish() <https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hiperf-py-2e/img/1.png>
if __name__ == "__main__":
writer = nsq.Writer(["127.0.0.1:4150"])
handler = partial(calculate_prime, writer=writer)
reader = nsq.Reader(
message_handler=handler,
nsqd_tcp_addresses=["127.0.0.1:4150"],
topic="numbers",
channel="worker_group_a",
)
nsq.run()
当我们处理完一条消息时,我们必须通知 NSQ。这将确保在失败时消息不会重新传递给另一个读者。
注意
我们可以通过在消息接收后在消息处理程序中启用 message.enable_async()
来异步处理消息。但是请注意,NSQ 使用较旧的回调机制与 tornado
的 IOLoop(在 tornado
中讨论)。
要设置 NSQ 生态系统,在我们的本地机器上启动一个 nsqd
实例:⁵
$ nsqd
[nsqd] 2020/01/25 13:36:39.333097 INFO: nsqd v1.2.0 (built w/go1.12.9)
[nsqd] 2020/01/25 13:36:39.333141 INFO: ID: 235
[nsqd] 2020/01/25 13:36:39.333352 INFO: NSQ: persisting topic/channel metadata
to nsqd.dat
[nsqd] 2020/01/25 13:36:39.340583 INFO: TCP: listening on [::]:4150
[nsqd] 2020/01/25 13:36:39.340630 INFO: HTTP: listening on [::]:4151
现在我们可以启动尽可能多的 Python 代码实例(Example 10-9)。事实上,我们可以让这些实例在其他计算机上运行,只要 nsqd_tcp_address
在 nsq.Reader
实例化中的引用仍然有效。这些消费者将连接到 nsqd
并等待在 numbers 主题上发布的消息。
数据可以通过多种方式发布到numbers主题。我们将使用命令行工具来完成这个任务,因为了解如何操作系统对于正确处理它至关重要。我们可以简单地使用 HTTP 接口将消息发布到主题:
$ for i in `seq 10000`
> do
> echo {\"number\": $i} | curl -d@- "http://127.0.0.1:4151/pub?topic=numbers"
> done
当此命令开始运行时,我们正在向numbers主题中发布包含不同数字的消息。与此同时,我们所有的生产者将开始输出状态消息,指示它们已经看到并处理了消息。此外,这些数字正在发布到prime或non_prime主题中的一个。这使我们能够有其他数据消费者连接到这些主题中的任何一个来获取原始数据的过滤子集。例如,只需要质数的应用程序可以简单地连接到prime主题,并不断地获得其计算所需的新质数。我们可以使用nsqd
的stats
HTTP 端点来查看我们的计算状态:
$ curl "http://127.0.0.1:4151/stats"
nsqd v1.2.0 (built w/go1.12.9)
start_time 2020-01-25T14:16:35Z
uptime 26.087839544s
Health: OK
Memory:
heap_objects 25973
heap_idle_bytes 61399040
heap_in_use_bytes 4661248
heap_released_bytes 0
gc_pause_usec_100 43
gc_pause_usec_99 43
gc_pause_usec_95 43
next_gc_bytes 4194304
gc_total_runs 6
Topics:
[non_prime ] depth: 902 be-depth: 0 msgs: 902 e2e%:
[numbers ] depth: 0 be-depth: 0 msgs: 3009 e2e%:
[worker_group_a ] depth: 1926 be-depth: 0 inflt: 1
def: 0 re-q: 0 timeout: 0
msgs: 3009 e2e%:
[V2 electron ] state: 3 inflt: 1 rdy: 1 fin: 1082
re-q: 0 msgs: 1083 connected: 15s
[prime ] depth: 180 be-depth: 0 msgs: 180 e2e%:
Producers:
[V2 electron ] msgs: 1082 connected: 15s
[prime ] msgs: 180
[non_prime ] msgs: 902
我们可以看到numbers主题有一个订阅组,worker_group_a,有一个消费者。此外,订阅组有一个很大的深度,有 1,926 条消息,这意味着我们正在将消息放入 NSQ 的速度比我们处理它们的速度快。这将提示我们增加更多的消费者,以便我们有更多的处理能力来处理更多的消息。此外,我们可以看到此特定消费者已连接了 15 秒,已处理了 1,083 条消息,并且当前有 1 条消息正在传输中。此状态端点为调试您的 NSQ 设置提供了相当多的信息!最后,我们看到prime和non_prime主题,它们没有订阅者或消费者。这意味着消息将被存储,直到有订阅者请求数据为止。
注意
在生产系统中,您可以使用更强大的工具nsqadmin
,它提供了一个具有非常详细的所有主题/订阅者和消费者概览的 Web 界面。此外,它允许您轻松地暂停和删除订阅者和主题。
要实际看到消息,我们将为prime(或non_prime)主题创建一个新的消费者,简单地将结果存档到文件或数据库中。或者,我们可以使用nsq_tail
工具来窥探数据并查看其内容:
$ nsq_tail --topic prime -n 5 --nsqd-tcp-address=127.0.0.1:4150
2020/01/25 14:34:17 Adding consumer for topic: prime
2020/01/25 14:34:17 INF 1 [prime/tail574169#ephemeral] (127.0.0.1:4150)
connecting to nsqd
{"number": 1, "prime": true}
{"number": 3, "prime": true}
{"number": 5, "prime": true}
{"number": 7, "prime": true}
{"number": 11, "prime": true}
其他要注意的集群工具
使用队列的作业处理系统自计算机科学行业开始就存在,当时计算机速度非常慢,需要处理大量作业。因此,有许多队列库,其中许多可以在集群配置中使用。我们强烈建议您选择一个成熟的库,并有一个活跃的社区支持它,并支持您需要的相同功能集而不包含太多其他功能。
库拥有的功能越多,你就会发现越多的方法来误配置它,并浪费时间进行调试。在处理集群解决方案时,简单性 通常 是正确的目标。以下是一些常用的集群解决方案:
-
ZeroMQ 是一个低级别且高效的消息传递库,可以在节点之间发送消息。它原生支持发布/订阅范式,并且可以通过多种传输方式进行通信(TCP、UDP、WebSocket 等)。它相当底层,不提供太多有用的抽象,这可能会使其使用有些困难。尽管如此,它在 Jupyter、Auth0、Spotify 等许多地方都有使用!
-
Celery(BSD 许可证)是一个广泛使用的异步任务队列,采用分布式消息架构,用 Python 编写。它支持 Python、PyPy 和 Jython。通常情况下,它使用 RabbitMQ 作为消息代理,但也支持 Redis、MongoDB 和其他存储系统。它经常用于 Web 开发项目中。Andrew Godwin 在 “Lanyrd.com 上的任务队列 (2014)” 中讨论了 Celery。
-
Airflow 和 Luigi 使用有向无环图将依赖任务链接成可靠运行的序列,配备监控和报告服务。它们在数据科学任务中被广泛应用于工业界,我们建议在自定义解决方案之前先进行审查。
-
[ 亚马逊简单队列服务 (SQS) 是集成到 AWS 中的作业处理系统。作业消费者和生产者可以位于 AWS 内部,也可以是外部的,因此 SQS 容易上手,并支持轻松迁移到云端。许多语言都有对应的库支持。
Docker
Docker 是 Python 生态系统中的一个重要工具。然而,它解决的问题在处理大型团队或集群时尤为重要。特别是 Docker 有助于创建可复制的环境来运行代码,在其中共享/控制运行时环境,轻松地在团队成员之间共享可运行代码,并根据资源需求将代码部署到节点集群中。
Docker 的性能
有一个关于 Docker 的常见误解,即它会大幅降低其运行应用程序的性能。虽然在某些情况下这可能是正确的,但通常并非如此。此外,大多数性能降低几乎总是可以通过一些简单的配置更改来消除。
就 CPU 和内存访问而言,Docker(以及所有其他基于容器的解决方案)不会导致任何性能降级。这是因为 Docker 简单地在主机操作系统中创建一个特殊的命名空间,代码可以在其中正常运行,尽管受到与其他运行程序不同的约束。基本上,Docker 代码以与计算机上的每个其他程序相同的方式访问 CPU 和内存;然而,它可以有一组单独的配置值来微调资源限制。⁶
这是因为 Docker 是操作系统级虚拟化的一个实例,而不是像 VMware 或 VirtualBox 这样的硬件虚拟化。在硬件虚拟化中,软件运行在“虚拟”硬件上,访问所有资源都会引入开销。另一方面,操作系统虚拟化使用本地硬件,但在“虚拟”操作系统上运行。由于 cgroups
Linux 功能,这种“虚拟”操作系统可以紧密耦合到正在运行的操作系统中,这使得几乎没有开销地运行成为可能。
警告
cgroups
是 Linux 内核中的一个特定功能。因此,这里讨论的性能影响仅限于 Linux 系统。事实上,要在 macOS 或 Windows 上运行 Docker,我们首先必须在硬件虚拟化环境中运行 Linux 内核。Docker Machine 是一个帮助简化此过程的应用程序,它使用 VirtualBox 来完成这一过程。因此,在 Linux 系统上运行时,由硬件虚拟化部分引起的性能开销将大大减少。
例如,我们可以创建一个简单的 Docker 容器来运行来自示例 6-17 的二维扩散代码。作为基准,我们可以在主机系统的 Python 上运行代码以获取基准:
$ python diffusion_numpy_memory2.py
Runtime for 100 iterations with grid size (256, 256): 1.4418s
要创建我们的 Docker 容器,我们必须创建一个包含 Python 文件 diffusion_numpy_memory2.py、一个用于依赖关系的 pip
要求文件和一个 Dockerfile 的目录,如示例 10-10 所示。
示例 10-10. 简单的 Docker 容器
$ ls
diffusion_numpy_memory2.py
Dockerfile
requirements.txt
$ cat requirements.txt
numpy>=1.18.0
$ cat Dockerfile
FROM python:3.7
WORKDIR /usr/src/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD python ./diffusion_numpy_memory2.py
Dockerfile 从指定我们希望用作基础的容器开始。这些基础容器可以是各种基于 Linux 的操作系统或更高级别的服务。Python 基金会为所有主要 Python 版本提供官方容器,这使得选择要使用的 Python 版本非常简单。接下来,我们定义我们的工作目录的位置(选择 /usr/src/app
是任意的),将我们的要求文件复制到其中,并开始设置我们的环境,就像我们在本地机器上使用 RUN
命令一样。
在正常设置开发环境和在 Docker 上设置环境之间的一个主要区别是COPY
命令。它们将文件从本地目录复制到容器中。例如,requirements.txt 文件被复制到容器中,以便在pip install
命令时使用。最后,在Dockerfile的末尾,我们将当前目录中的所有文件复制到容器中,并告诉 Docker 在容器启动时运行python ./diffusion_numpy_memory2.py
。
注意
在 示例 10-10 的Dockerfile中,初学者经常会想知道为什么我们首先只复制需求文件,然后再将整个目录复制到容器中。在构建容器时,Docker 会尝试缓存构建过程的每一步。为了确定缓存是否仍然有效,检查复制来回的文件的内容。通过首先只复制需求文件,然后再移动其余目录,如果需求文件未发生变化,则只需运行一次pip install
。如果仅 Python 源代码发生了更改,新构建将使用缓存的构建步骤,并直接跳过第二个COPY
命令。
现在我们已经准备好构建和运行容器了,可以为其命名和打标签。容器名称通常采用*<username>*/*<project-name>*
的格式,⁷而可选的标签通常是描述当前代码版本的描述性标签,或者简单地是标签latest
(这是默认的,如果未指定标签将自动应用)。为了帮助版本管理,通常的约定是始终将最新构建标记为latest
(当进行新构建时将被覆盖),以及一个描述性标签,以便将来可以轻松找到这个版本:
$ docker build -t high_performance/diffusion2d:numpy-memory2 \
-t high_performance/diffusion2d:latest .
Sending build context to Docker daemon 5.632kB
Step 1/6 : FROM python:3.7
---> 3624d01978a1
Step 2/6 : WORKDIR /usr/src/app
---> Running in 04efc02f2ddf
Removing intermediate container 04efc02f2ddf
---> 9110a0496749
Step 3/6 : COPY requirements.txt ./
---> 45f9ecf91f74
Step 4/6 : RUN pip install --no-cache-dir -r requirements.txt
---> Running in 8505623a9fa6
Collecting numpy>=1.18.0 (from -r requirements.txt (line 1))
Downloading https://.../numpy-1.18.0-cp37-cp37m-manylinux1_x86_64.whl (20.1MB)
Installing collected packages: numpy
Successfully installed numpy-1.18.0
You are using pip version 18.1, however version 19.3.1 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Removing intermediate container 8505623a9fa6
---> 5abc2df1116f
Step 5/6 : COPY . .
---> 52727a6e9715
Step 6/6 : CMD python ./diffusion_numpy_memory2.py
---> Running in c1e885b926b3
Removing intermediate container c1e885b926b3
---> 892a33754f1d
Successfully built 892a33754f1d
Successfully tagged high_performance/diffusion2d:numpy-memory2
Successfully tagged high_performance/diffusion2d:latest
$ docker run high_performance/diffusion2d:numpy-memory2
Runtime for 100 iterations with grid size (256, 256): 1.4493s
我们可以看到,在任务主要依赖于 CPU/内存时,Docker 的核心并不比在主机上运行慢。然而,与任何事物一样,没有免费的午餐,有时 Docker 的性能会受到影响。尽管优化 Docker 容器的全面讨论超出了本书的范围,但在为高性能代码创建 Docker 容器时,我们提供以下考虑事项清单:
-
当你将过多数据复制到 Docker 容器中,甚至是在 Docker 构建过程中同一个目录下的数据过多时,都要小心。如果
docker build
命令的第一行所标识的build context
过大,性能可能会受到影响(通过 .dockerignore 文件可以解决这个问题)。 -
Docker 使用各种文件系统技巧在彼此之上层叠文件系统。这有助于构建缓存,但与主机文件系统交互可能会比较慢。当需要快速访问数据时,请使用主机级挂载,并考虑使用设置为只读的
volumes
,选择适合你基础设施的卷驱动程序。 -
Docker 为所有容器创建了一个虚拟网络,使大多数服务保持在网关后面,这对于隐匿大部分服务非常有用,但也增加了轻微的网络开销。对于大多数用例,这种开销可以忽略不计,但可以通过更改网络驱动程序来减轻。
-
使用特殊的 Docker 运行时驱动程序可以访问 GPU 和其他主机级设备。例如,
nvidia-docker
允许 Docker 环境轻松使用连接的 NVIDIA GPU。通常,设备可以通过--device
运行时标志提供。
像往常一样,重要的是对你的 Docker 容器进行性能分析,以了解存在的问题及其在效率方面的简单解决方案。docker stats
命令提供了一个良好的高层视图,帮助理解容器当前的运行时性能。
Docker 的优势
到目前为止,似乎 Docker 只是在性能方面增加了一系列新的问题。然而,运行时环境的可重现性和可靠性远远超过了任何额外复杂性。
在本地,可以访问我们之前运行的所有 Docker 容器,这使我们可以快速重新运行和重新测试我们代码的先前版本,而不必担心运行时环境的更改,比如依赖项和系统包(示例 10-11 显示了我们可以使用简单的 docker_run
命令运行的容器列表)。这使得持续测试性能回归变得非常容易,否则这将是难以复现的。
示例 10-11. Docker 标签以跟踪先前的运行时环境
$ docker images -a
REPOSITORY TAG IMAGE ID
highperformance/diffusion2d latest ceabe8b555ab
highperformance/diffusion2d numpy-memory2 ceabe8b555ab
highperformance/diffusion2d numpy-memory1 66523a1a107d
highperformance/diffusion2d python-memory 46381a8db9bd
highperformance/diffusion2d python 4cac9773ca5e
使用容器注册表带来了许多额外好处,它允许使用简单的 docker
pull
和 docker
push
命令存储和共享 Docker 镜像,类似于 git
的方式。这使我们可以将所有容器放在公共可用的位置,允许团队成员拉取变更或新版本,并立即运行代码。
注
这本书是使用 Docker 容器共享的一个很好的例子,用于标准化运行时环境。为了将这本书从其编写的标记语言 asciidoc
转换为 PDF,我们之间共享了一个 Docker 容器,这样我们可以可靠且可重复地构建书籍工件。这种标准化节省了我们无数小时,在第一版中我们会遇到一个构建问题,而另一个人却无法复制或帮助调试。
运行 docker pull highperformance/diffusion2d:latest
要比克隆存储库并执行可能必需的所有相关设置容易得多。对于研究代码来说,这一点尤为真实,因为可能存在一些非常脆弱的系统依赖性。将所有内容放入一个可以轻松拉取的 Docker 容器中意味着可以跳过所有这些设置步骤,并且可以轻松运行代码。因此,代码可以更轻松地共享,编码团队可以更有效地协同工作。
最后,结合 kubernetes
和其他类似的技术,将您的代码 Docker 化有助于确保其能够使用所需的资源进行运行。Kubernetes 允许您创建一个节点集群,每个节点都标记有其可能具备的资源,并在节点上协调运行容器。它会确保正确数量的实例在运行,而由于 Docker 虚拟化的作用,代码将在您保存它时相同的环境中运行。在使用集群时,最大的问题之一是确保集群节点具有与您工作站相同的运行环境,使用 Docker 虚拟化完全解决了这个问题。⁸
总结
在本书中,我们已经了解了性能分析来理解代码中的缓慢部分,使用 numpy
进行编译并加快代码运行速度,以及多进程和多计算机的各种方法。此外,我们还调查了容器虚拟化来管理代码环境并帮助集群部署。在倒数第二章中,我们将探讨通过不同的数据结构和概率方法减少内存使用的方法。这些课程可以帮助您将所有数据保存在一台计算机上,避免运行集群的需要。
¹ 在 AWS 工作时,我们可以将我们的 nsqd
进程运行在预留实例上,而我们的消费者则在一组竞价实例上运行,这样做有很大的优势。
² 这种异步性来自于 NSQ 的发送消息给消费者的推送式协议。这使得我们的代码可以在后台进行异步读取 NSQ 连接,并在发现消息时唤醒。
³ 这种数据分析的链接被称为流水线处理,可以有效地对同一组数据执行多种类型的分析。
⁴ 您还可以通过 HTTP 调用手动发布消息;但是,这个 nsq.Writer
对象简化了大部分的错误处理。
⁵ 例如,我们可以将 NSQ 直接安装到系统上,通过将提供的二进制文件解压缩到我们的 PATH
环境变量中。或者,您可以使用 Docker,在“Docker”中讨论的方式来轻松运行最新版本。
⁶ 这种微调可以用来调整进程可以访问的内存量,或者可以使用的 CPU 核心数量,甚至可以控制 CPU 使用量的大小。
⁷ 当将构建的容器推送到存储库时,容器名称中的*username*
部分非常有用。
⁸ 一个很棒的入门教程,可以在https://oreil.ly/l9jXD找到。