事实证明,除了能够为视频游戏渲染图形外,图形处理单元(GPU)还为普通消费者提供了一种方便的方式来进行大规模并行**计算-一个普通人现在可以从当地的电子商店购买一张 2000 美元的现代 GPU 卡,将其插入家中的 PC,然后几乎立即将其用于计算能力,这在 5 年或 10 年前只有顶级公司和大学的超级计算实验室才有。近年来,GPU 的这种开放可访问性在许多方面变得显而易见,这可以通过以下新闻的简要观察来揭示:加密货币采矿者使用 GPU 生成比特币等数字货币,遗传学家和生物学家使用 GPU 进行 DNA 分析和研究,物理学家和数学家使用 GPU 进行大规模模拟,人工智能研究人员现在可以编程 GPU 编写剧本和作曲,而大型互联网公司,如谷歌和 Facebook,则使用带有 GPU 的服务器的农场来完成大规模机器学习任务……列表层出不穷。
这本书的主要目的是让你跟上 GPU 编程的速度,这样你也可以尽快开始使用他们的能力,不管你的最终目标是什么。我们的目标是涵盖如何编程 GPU 的核心要素,而不是提供复杂的技术细节和 GPU 工作原理图。在本书的结尾,我们将提供更多的资源,以便您可以进一步专业化,并应用您对 GPU 的新知识。(有关特定所需技术知识和硬件的更多详细信息,请参见本节。)
在本书中,我们将与CUDA合作,这是 NVIDIA 于 2007 年首次发布的通用 GPU(GPGPU)编程框架。虽然 CUDA 是 NVIDIA GPU 的专有技术,但它是一个成熟稳定的平台,相对易于使用,提供了一套无与伦比的第一方加速数学和 AI 相关库,并且在安装和集成方面的麻烦最小。此外,还有现成的标准化 Python 库,如 PyCUDA 和 Scikit-CUDA,这使得有抱负的 GPU 程序员更容易访问 GPGPU 编程。出于这些原因,我们选择与 CUDA 合作出版这本书。
CUDA is always pronounced coo-duh, and never as the acronym C-U-D-A! CUDA originally stood for Compute Unified Device Architecture, but Nvidia has dropped the acronym and now uses CUDA as a proper name written in all-caps.
现在,我们将以阿姆达尔定律的概述开始我们的 GPU 编程之旅。Amdahl 定律是一种简单但有效的方法,用于估计我们可以通过将程序或算法卸载到 GPU 上获得的潜在速度增益;这将帮助我们确定是否值得我们努力重写代码以使用 GPU。然后,我们将简要回顾一下如何使用cProfile模块分析 Python 代码,以帮助我们找到代码中的瓶颈。
本章的学习成果如下:
- 理解阿姆达尔定律
- 在你的准则中应用阿姆达尔定律
- 使用cProfile模块对 Python 代码进行基本评测
本章建议安装 Anaconda Python 2.7:
https://www.anaconda.com/download/
本章的代码也可在 GitHub 上获得:
https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA
For more information about the pre-requisites, check the preface of this book; for the software and hardware requirements, check the README section in https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA.
在我们深入挖掘 GPU 的潜力之前,我们首先必须认识到,与现代 Intel/AMD 中央处理器(CPU)相比,GPU 的计算能力在哪里——这种能力并不在于它的时钟速度高于 CPU,也不在于单个内核的复杂性或特殊设计。单个 GPU 内核实际上非常简单,与现代的单个 CPU 内核相比处于劣势,后者使用许多奇特的工程技巧,例如分支预测来减少计算的延迟。延迟是指执行单个计算的开始到结束的持续时间。
GPU 的强大源于这样一个事实,即有比 CPU 多得多的内核,这意味着吞吐量向前迈进了一大步。吞吐量这里指的是可以同时执行的计算数量。让我们用一个类比来更好地理解这意味着什么。GPU 就像一条很宽的城市道路,可以同时处理许多行驶缓慢的车辆(高吞吐量、高延迟),而 CPU 就像一条狭窄的公路,一次只能容纳几辆车,但可以让每辆车更快地到达目的地(低吞吐量、低延迟)。
我们可以通过查看这些新 GPU 有多少核来了解吞吐量的增加。给你一个想法,平均 Intel 或 AMD CPU 只有两到八个内核,而入门级消费级 NVIDIA GTX 1050 GPU 有640 个内核,而新的顶级 NVIDIA RTX 2080 Ti 有4352 个内核!我们可以利用这种巨大的吞吐量,只要我们知道如何正确地将我们希望加速的任何程序或算法并行化。通过并行化,我们的意思是重写一个程序或算法,这样我们就可以将我们的工作负载拆分为同时在多个处理器上并行运行。让我们想想现实生活中的一个类比。
假设你正在建造一座房子,并且你已经准备好了所有的设计和材料。你雇佣一个工人,你估计建造房子需要 100 个小时。让我们假设这座房子的建造方式可以让你雇佣的每一个额外的工人都能完美地分配工作也就是说,两个工人需要 50 个小时,四个工人需要 25 个小时,十个工人建造房屋的 10 小时建造房屋的时间将被 100 除以你雇佣的工人数量。这是一个可并行化任务的示例。
我们注意到,两个工人完成这项任务的速度是两个工人的两倍,十个工人一起完成这项任务的速度是十个工人完成速度的十倍(即,并行)而不是一个工人单独建造房屋(即,串行)——也就是说,如果N是工人的数量,那么它将是N倍的速度。在这种情况下,N被称为在任务的串行版本上并行化任务的加速。
在我们开始编写给定算法的并行版本之前,我们通常首先估算并行化将给我们的任务带来的潜在**加速。这可以帮助我们确定是否值得花费资源和时间来编写程序的并行化。因为现实生活比我们在这里给出的例子更复杂,很明显,我们不可能完美地并行每个程序,在大多数情况下,我们的程序只有一部分可以很好地并行,而其余部分必须串行运行。
我们现在将推导出阿姆达尔定律,这是一个简单的算术公式,用于估算将串行程序中的部分代码并行到多个处理器上可能产生的潜在速度增益。我们将通过继续前面的建造房屋的类比来实现这一点。
上一次,我们只考虑房子的实际物理结构作为整个持续时间,但现在,我们也将考虑时间来设计房子到建造房屋的持续时间。假设世界上只有一个人有能力为你设计房子,你需要 100 个小时来设计房子的平面图。地球上没有任何其他人可以与你的建筑才华相比,因此这部分任务根本不可能在其他建筑师之间分配,也就是说,设计你的房子需要 100 个小时,不管你有什么资源或你可以雇佣多少人。因此,如果你只有一个工人来建造你的房子,那么建造你的房子所需的全部时间将是 200-100 个小时,让你来设计,让一个工人来建造 100 个小时。如果我们雇佣两名工人,这将需要 150 个小时。房子的设计时间将保持在 100 个小时,而建筑将需要 50 个小时。很明显,建造房屋的总小时数为 100+100/N,其中N是我们雇佣的劳动力数量。
现在,让我们后退一步,想想如果我们雇佣一名工人,建造房子需要多少时间,我们最终用这个来确定在雇佣额外工人时的加速;也就是说,这个过程会快多少倍。如果我们雇佣一名工人,我们会发现设计和建造房屋所需的时间是相同的——100 小时。因此,我们可以说,在设计上花费的时间部分是.5(50%),建造房屋所需的时间部分是.5(50%)——当然,这两部分加起来是 1,也就是 100%。我们想做一个比较,当我们增加劳动力时,如果我们有两个劳动力,施工时间部分减少一半,因此与我们任务的原始序列版本相比,这将花费原始任务时间的.5+.5/2=.75(75%),以及.75 x 200 小时是 150 小时,因此我们可以看到这是可行的。此外,我们可以看到,如果我们有N劳动力,我们可以计算我们的平行结构与N劳动力的时间百分比,公式为。5+。5/N。
现在,让我们确定通过增加额外劳动力而获得的加速。因为如果我们有两个工人,建造一座房子需要 75%的时间,我们可以用.75 的倒数来确定我们并行化的速度,也就是说,速度将是 1/.75,这大约是我们只有一个工人时速度的 1.33 倍。在这种情况下,我们看到如果我们有N劳动力,则加速比将为 1/(.5+.5/N。
我们知道,随着我们增加越来越多的劳动力,.5/N 将非常接近于 0,因此我们可以看到,当您并行化此任务时,您可以获得的加速总是有一个上限,即 1/(.5+0)=2。我们可以将原始串行时间除以估计的最大加速比,以确定此任务所需的绝对最小时间量-200/2=100 小时。
我们刚刚应用于并行编程中确定加速比的原理称为阿姆达尔定律。它只需要知道我们原始串行程序中代码的可并行执行时间比例,即所谓的p,以及我们可用的处理器核数N。
The proportion of execution time for code that is not parallelizable in this case is always 1 – p, so we only need to know p.
我们现在可以用阿姆达尔定律计算加速比,如下所示:
总之,阿姆达尔定律是一个简单的公式,它允许我们粗略地(*非常粗略地)*估计一个至少可以部分并行化的程序的潜在加速比。如果我们知道我们可以并行化的代码的比例(p),以及我们可以在多少个内核上运行我们的并行化代码(N),那么这可以提供一个关于编写特定串行程序的并行版本是否值得的总体想法。
我们现在准备看到一个非常标准的并行计算示例,我们将在本文后面重新讨论一个生成Mandelbrot 集图像的算法。让我们首先明确定义我们的意思。
对于给定的复数,c为定义了一个递归序列,其中和为,如果znn在n增加到无穷大时仍以 2 为界,那么我们将说【T12 c 是 Mandelbrot 集的一员
回想一下,我们可以将复数视为位于二维笛卡尔平面上,其中x轴表示实部,y 轴表示虚部。因此,我们可以很容易地用一个非常吸引人(并且众所周知)的图形来可视化曼德尔布罗特集。在这里,我们将在复杂笛卡尔平面上用较浅的阴影表示 Mandelbrot 集的成员,用较深的阴影表示非成员,如下所示:
现在,让我们考虑一下如何在 Python 中生成这个集合。我们必须首先考虑一些事情,因为我们显然不能检查每一个复数是否在曼德尔布罗特集中,我们必须选择一定的范围来检查;我们必须确定在每一个范围内我们将考虑多少点(Po.T1 宽度,高度 T2);以及我们将检查(max_iters
的n的最大值zn。我们现在可以准备实现一个函数来生成 Mandelbrot 集的图形,我们通过迭代序列中图形中的每个点来实现。
我们将从导入 NumPy 库开始,这是一个数字库,我们将在本文中充分利用它。我们在这里的实现是在simple_mandelbrot
函数中实现的。我们首先使用 NumPy 的linspace
函数生成一个晶格,该晶格将充当一个离散的复杂平面(接下来的代码应该相当简单):
import numpy as np
def simple_mandelbrot(width, height, real_low, real_high, imag_low, imag_high, max_iters):
real_vals = np.linspace(real_low, real_high, width)
imag_vals = np.linspace(imag_low, imag_high, height)
# we will represent members as 1, non-members as 0.
mandelbrot_graph = np.ones((height,width), dtype=np.float32)
for x in range(width):
for y in range(height):
c = np.complex64( real_vals[x] + imag_vals[y] * 1j )
z = np.complex64(0)
for i in range(max_iters):
z = z**2 + c
if(np.abs(z) > 2):
mandelbrot_graph[y,x] = 0
break
return mandelbrot_graph
现在,我们想添加一些代码,将 Mandelbrot 集的图像转储为 PNG 格式的文件,因此让我们在开头添加适当的标题:
from time import time
import matplotlib
# the following will prevent the figure from popping up
matplotlib.use('Agg')
from matplotlib import pyplot as plt
现在,让我们添加一些代码来生成 Mandelbrot 集并将其转储到一个文件中,然后使用 time 函数来计时这两个操作:
if __name__ == '__main__':
t1 = time()
mandel = simple_mandelbrot(512,512,-2,2,-2,2,256, 2)
t2 = time()
mandel_time = t2 - t1
t1 = time()
fig = plt.figure(1)
plt.imshow(mandel, extent=(-2, 2, -2, 2))
plt.savefig('mandelbrot.png', dpi=fig.dpi)
t2 = time()
dump_time = t2 - t1
print 'It took {} seconds to calculate the Mandelbrot graph.'.format(mandel_time)
print 'It took {} seconds to dump the image.'.format(dump_time)
现在让我们运行这个程序(这也可以作为 GitHub 存储库中文件夹1
中的mandelbrot0.py
文件获得):
生成 Mandelbrot 集大约需要 14.62 秒,转储图像大约需要 0.11 秒。如我们所见,我们逐点生成 Mandelbrot 设定值;不同点的值之间没有相互依赖关系,因此,它本质上是一个可并行化的函数。相反,转储映像的代码不能并行化。
现在,让我们用阿姆达尔定律来分析。如果我们在这里并行化代码,我们能得到什么样的加速?总的来说,两个程序的运行时间约为 14.73 秒;由于我们可以并行化 Mandelbrot 集生成,因此可以说可并行化代码的执行时间部分为p=14.62/14.73=.99。这个程序是 99%可并行的!
我们可能得到什么样的加速?嗯,我目前正在使用入门级 GTX 1050 GPU 和 640 核的笔记本电脑;因此,当我们使用公式时,我们的N将为 640。我们计算加速比如下:
这无疑是非常好的,并向我们表明,值得我们努力编程我们的算法以使用 GPU。请记住,阿姆达尔定律只给出了一个非常粗略的估计!当我们将计算转移到 GPU 上时,会有额外的考虑因素,例如 CPU 向 GPU 发送和接收数据所需的额外时间;或者,卸载到 GPU 的算法只能部分并行化。
我们在前面的示例中看到,我们可以使用 Python 中的标准time
函数分别计时不同的函数和组件。虽然这种方法适用于我们的小示例程序,但对于调用许多不同函数的大型程序来说,这并不总是可行的,其中一些函数可能不值得我们进行并行化,甚至在 CPU 上进行优化。我们的目标是找到一个程序的瓶颈和热点,即使我们感觉精力充沛,在我们所做的每一个功能调用中都使用了 T1,我们可能会错过一些东西,或者可能有一些系统或库调用,我们甚至不认为这会减缓事情的进展。在我们考虑重写代码以在 GPU 上运行之前,我们应该找到要卸载到 GPU 上的候选代码部分;我们必须始终遵循美国著名计算机科学家唐纳德·克努特(Donald Knuth)的睿智之言:过早优化是万恶之源。
我们使用所谓的剖析器来发现代码中的这些热点和瓶颈。一个剖析器将方便地让我们看到我们的程序在哪里花费了最多的时间,并允许我们相应地进行优化。
我们将主要使用cProfile模块来检查我们的代码。这个模块是一个标准的库函数,包含在每个现代 Python 安装中。我们可以使用-m cProfile
从命令行运行探查器,并使用-s cumtime
指定我们希望按照每个函数花费的累计时间来组织结果,然后使用>
操作符将输出重定向到文本文件中。
This will work on both the Linux Bash or Windows PowerShell command line.
现在让我们试试这个:
现在,我们可以使用我们最喜欢的文本编辑器查看文本文件的内容。请记住,程序的输出将包含在文件的开头:
现在,由于我们没有删除原始示例中对time
的引用,我们在开头的前两行看到了它们的输出。然后我们可以看到这个程序中函数调用的总数,以及运行它的累计时间。
随后,我们有一个在程序中调用的函数列表,从累积最耗时的函数到累积最少的函数排序;第一行是程序本身,第二行是我们程序中的simple_mandelbrot
函数。(请注意,这里的时间与我们使用time
命令测量的时间一致)。在此之后,我们可以看到许多与将 Mandelbrot 图转储到文件相关的库和系统调用,所有这些都需要相对较少的时间。我们使用cProfile的输出来推断瓶颈在给定程序中的位置。
与 CPU 相比,使用 GPU 的主要优势在于其提高的吞吐量,这意味着我们可以在 GPU 上同时执行比 CPU 上更多的并行代码;GPU 不能使递归算法或非并行算法更快一些。我们看到一些任务,比如建造房子的例子,在这个例子中只是部分可并行,我们无法加快设计房子的过程(在本例中本质上是序列,但我们可以加快*构建的过程,*通过雇佣更多的劳动力(在这种情况下是可并行的)。
我们使用这个类比来推导阿姆达尔定律,这是一个公式,如果我们知道可并行化代码的执行时间百分比,以及我们必须运行这些代码的处理器数量,它可以粗略估计程序的潜在加速比。然后,我们应用阿姆达尔定律来分析一个生成 Mandelbrot 集并将其转储到图像文件的小程序,我们确定这将是一个很好的 GPU 并行化候选者。最后,我们简要概述了cPython模块的评测代码;这使我们能够看到程序中的瓶颈在哪里,而无需明确地对函数调用进行计时。
现在我们已经有了一些基本概念,并且有了学习 GPU 编程的动力,我们将在下一章中设置基于 Linux 或 Windows 10 的 GPU 编程环境。在下一章中,我们将立即深入到 GPU 编程的世界,我们将实际编写一个基于 GPU 的 Mandelbrot 程序版本,我们在本章中看到了这个版本。
- 在本章的曼德尔布罗特例子中有三个
for
陈述;然而,我们只能对前两个进行并行化。为什么我们不能在这里并行化所有的for
循环? - 当我们应用阿姆达尔定律将串行 CPU 算法卸载到 GPU 时,它没有解释什么?
- 假设您获得了对三个新的绝密 GPU 的独占访问权,这三个 GPU 在所有方面都是相同的,除了核心数,第一个 GPU 有 131072 个核心,第二个 GPU 有 262144 个核心,第三个 GPU 有 524288 个核心。如果您将 Mandelbrot 示例并行化并卸载到这些 GPU 上(生成 512 x 512 像素的图像),第一个和第二个 GPU 之间的计算时间会有差异吗?第二个和第三个 GPU 之间如何?
- 在阿姆达尔定律的背景下,指定某些算法或代码块为可并行化有什么问题吗?
- 为什么我们应该使用分析器而不是仅仅使用 Python 的
time
函数?