并行计算通常涉及两个不同的计算技术领域。
计算机架构关注的是在结构级别上支持并行性,而并行编程设计关注的是充分使用计算机架构的计算能力来并发地解决问题。为了在软件中实现并行执行,硬件必须提供一个支持并行执行多进程或多线程的平台。
大多数现代处理器都应用了哈佛体系结构(Harvard architecture),如图1-1所示,
它主要由3个部分组成:
高性能计算的关键部分是中央处理单元(CPU),通常被称为计算机的核心。在早期的计算机中,一个芯片上只有一个CPU,这种结构被称为单核处理器。现在,芯片设计的趋势是将多个核心集成到一个单一的处理器上,以在体系结构级别支持并行性,这种形式通常被称为多核处理器。
因此,并行程序设计可以看作是将一个问题的计算分配给可用的核心以实现并行的过程。
当用计算机程序解决一个问题时,我们会很自然地把这个问题划分成许多的运算块,每一个运算块执行一个指定的任务,运算块依次执行的程序叫作串行程序。
区分两个计算单元之间的关系:
分析数据的相关性是最基本的内容,因为相关性是限制并行性的一个主要因素。
在应用程序中有两种基本的并行类型。
数据并行程序设计的第一步是把数据依据线程进行划分,以使每个线程处理一部分数据。
通常来说,有两种方法可以对数据进行划分:
块划分(block partitioning):
在块划分中,一组连续的数据被分到一个块内。每个数据块以任意次序被安排给一个线程,线程通常在同一时间只处理一个数据块。
周期划分(cyclic partitioning):
在周期划分中,更少的数据被分到一个块内。相邻的线程处理相邻的数据块,每个线程可以处理多个数据块。为一个待处理的线程选择一个新的块,就意味着要跳过和现有线程一样多的数据块。
在块划分中,每个线程仅需处理数据的一部分,而在周期划分中,每个线程要处理数据的多个部分。
通常,数据是在一维空间中存储的。即便是多维逻辑数据,仍然要被映射到一维物理地址空间中。如何在线程中分配数据不仅与数据的物理储存方式密切相关,并且与每个线程的执行次序也有很大关系。组织线程的方式对程序的性能有很大的影响。
程序性能通常对块的大小比较敏感。块划分与周期划分中划分方式的选择与计算机架构有密切关系。
弗林分类法(Flynn’s Taxonomy)根据指令和数据进入CPU的方式,将计算机架构分为4种不同的类型
单指令单数据(SISD),是传统的计算机,一种串行架构,只有一个核心,在任何时间点上只有一个指令流在处理一个数据流。
单指令多数据(SIMD),是现在大多计算机,一种并行架构,计算机上有多个核心,在任何时间点所有的核心只有一个指令处理不同的数据流。其最大优势在于编写代码时,程序员可以继续按照串行逻辑思考,但可以实现并加速并行数据操作,这些细节由编译起来负责。
多指令单数据(MISD),比较少见,每个核心通过使用多个指令流来处理同一数据流。
多指令多数据(MIMD),也是一种并行架构,多个核心使用多个指令流来异步处理多个数据流,从而实现空间上的并行性,许多MIMD还包括SIMD执行的子组件。
当前在架构层次已取得了许多进展,包括:
降低延迟,延迟是一个操作从开始到完成所需要的时间,常用微秒(ms)来表示,用来衡量完成一次操作的时间。
提高带宽,带宽是单位时间内可以处理的数据量,通常表示为MB/s或GB/s。
提高吞吐量,吞吐量是单位时间内成功处理的运算数量,通常表示为gflops(即每秒十亿次浮点运算数量),用来衡量在给定单位时间内处理的操作量。
按照内存组织方式,计算机架构也可以划分成下面两种类型:
分布式内存的多节点系统,在这种系统中大型计算引擎由许多网络相连的处理器构成,每个处理器都有自己的本地内存,处理器之间通过网络通信,这种系统通常称为集群。
共享内存的多处理器系统,由多个处理器组成,这些处理器要么与同一个物理内存相关联(如下图呀所示),要么共享一个低延迟的链路(PCI-Express或PCIe)。尽管共享内存意味着共享地址空间,但着并不意味着就是一个独立的物理内存。
这样的多处理器不仅包括由多个核心组成的单片机系统,即所谓的多核系统,还包括由多个芯片组成的计算机系统,其中每个芯片都有可能是多核的。多核架构已经永久地取代了单核架构。多核的进一步扩展就是“众核”架构,是由很多(几十到几百)核心组成的系统。
GPU代表着一种众核架构,几乎包括了前面所有的并行结构:多线程、MIMD(多指令多数据)、SIMD(单指令多数据),以及指令级并行。NVIDIA公司将这种架构称为SIMT(单指令多线程)。
GPU和CPU的来源并不相同。历史上,GPU是图形加速器。直到最近,GPU才演化成一个强大的、多用途的、完全可编程的,以及任务和数据并行的处理器,它非常适合解决大规模的并行计算问题。
尽管可以用多核和众核来区分CPU和GPU,但这两种核心完全不同:
同构是指使用的是同一架构下一个或多个处理器来执行一个应用,而异构计算使用一个处理器架构来执行一个应用,为任务选定合适它的架构,使其最终对性能有所改善。
尽管异构系统比传统的高性能计算系统有更大的优势,但目前对这种系统的有效利用受限于增加应用程序设计的复杂性。
CPU和GPU是两个独立的处理器,它们通过单个计算节点中的PCI-Express总线相连。
一个典型的异构计算节点包括两个多核CPU插槽和多个或更多的众核GPU。GPU并非独立运行平台,而是CPU的协处理器,因此必须通过PCIe总线与基于CPU的主机相连来进行操作,如下图所示。因此,CPU所在的位置被称为主机端,而GPU所在的位置则被称为设备端。
一个异构应用包括两个部分。
异构平台上执行的应用通常由CPU初始化。在设备端加载计算密集型任务之前,CPU代码负责管理设备端的环境、代码和数据。
在计算密集型应用中,往往有很多并行数据的程序段。GPU就是用来提高这些并行数据的执行速度的。当使用CPU上的一个与其物理上分离开的硬件组件来提高应用中的计算密集部分的执行速度时,这个组件就成为了一个硬件加速器。GPU可以说是最为常见的硬件加速器。
对于特定的应用程序,CPU和GPU都有自身的优点,如下图所示,两者结合能有效提高大规模计算问题的处理速度与性能:
CPU计算适合处理控制密集型任务,它针对动态工作负载进行了优化,这些动态工作负载由短序列的计算操作和不可预测的控制流程标记。
GPU计算适合处理包括数据并行的计算密集型任务,处理由计算任务主导的且带有简单控制流程的工作负载。
可以从并行级和数据规模两个方面来区分CPU和GPU应用范围:
如果问题的数据规模较小,但有复杂的控制逻辑和/或很少的并行性,则最好选择CPU来处理,因其有处理复杂逻辑和指令级并行性的能力;
如果问题包含大规模的待处理数据,并表现出大量数据并行性,则GPU是最好的选择,因其有大量可编程核心,可支持大规模多线程运算,且有更大的峰值带宽。
这种代码的编写方式能保证GPU与CPU相辅相成,从而使CPU+GPU系统的计算能力得以充分利用。为了支持使用CPU+GPU异构系统架构来执行应用程序,NVIDIA设计了一个被称为CUDA的编程模型。
CUDA是一种异构计算平台,通过它程序员可以像在CPU上那样通过GPU进行计算。CUDA可以通过CUDA加速库、编译器指令、应用程序接口和程序语言(C、C++、Fortran、Python)扩展来使用,如下图所示。CUDA C是标准ANSI C语言的一个扩展,它带有的少数语言扩展功能使异构编程成为可能,同时也能通过API来管理设备、内存和其他任务。
CUDA提供了两层API来管理GPU设备和组织线程,如下图所示:
这两种API是相互排斥的,你必须使用两者之一,从两者中混合函数调用是不可能的。本系列中所有例子都使用运行时API。
运行时API和驱动API之间没有明显的性能差异。在设备端,内核是如何使用内存以及你是如何组织线程的,对性能有更显著的影响。
与其他异构代码一样,CUDA程序包含了在CPU上运行的主机代码和在GPU上运行的设备代码。
NVIDIA的CUDA nvcc编译器在编译过程中将设备代码从主机代码中分离出来。
主机代码是标准的C代码,使用C编译器进行编译。
设备代码,也就是核函数,是用扩展的带有标记数据并行函数关键字的CUDA C语言编写的。设备代码通过nvcc进行编译。在链接阶段,在内核程序调用和显示GPU设备操作中添加CUDA运行时库。
在CUDA中,host和device是两个重要的概念,我们用host指代CPU及其内存,而用device指代GPU及其内存。CUDA程序中既包含host程序,又包含device程序,它们分别在CPU和GPU上运行。同时,host与device之间可以进行通信,这样它们之间可以进行数据拷贝。典型的CUDA程序的执行流程如下:
写一个CUDA C程序,你需要以下几个步骤:
首先,我们编写一个C语言程序来输出“Hello World”,
把代码保存到hello.cu中,
然后使用nvcc编译器来编译。
CUDA nvcc编译器和gcc编译器及其他编译器有相似的语义。
如果你运行可执行文件hello,将会输出:
$ vim hello.cu
$ nvcc hello.cu -o hello
$ ./hello
Hello World from CPU!
#include
__global__ void helloFromGPU(void)
{
printf("Hello World from GPU!\n");
}
int main(void)
{
printf("Hello World from CPU!\n");
helloFromGPU <<<1, 10>>>();
cudaDeviceReset();
return 0;
}
编写一个内核函数, 命名为helloFromGPU,用它来输出字符串“Hello World from GPU!”。
修饰符__global__告诉编译器这个函数将会从CPU中调用,然后在GPU上执行。
代码启动内核函数:helloFromGPU <<<1, 10>>>();
三重尖括号意味着从主线程到设备端代码的调用。一个内核函数通过一组线程来执
行,所有线程执行相同的代码。三重尖括号里面的参数是执行配置,用来说明使用多少线程来执行内核函数。
在这个例子中,有10个GPU线程被调用,且每个线程调用一次。
函数cudaDeviceRest()用来显式地释放和清空当前进程中与当前设备有关的所有资
源。
在nvcc命令行中使用-arch sm_60进行编译: 开关语句-arch sm_60使编译器为架构生成设备代码。
运行这个可执行文件,它将输出10条字符串“Hello World from GPU”,每个线程输出1条。
当helloFromGPU <<<2, 10>>>();
时,将输出20句Hello World from CPU!
$ nvcc -arch sm_60 hello.cu -o hello
$./hello
Hello World from CPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
Hello World from GPU!
CPU编程和GPU编程的主要区别是程序员对GPU架构的熟悉程度。用并行思维进行思考并对GPU架构有了基本的了解。
数据局部性在并行编程中是一个非常重要的概念。
数据局部性指的是数据重用,以降低内存访问的延迟。
数据局部性有两种基本类型:
时间局部性是指在相对较短的时间段内数据和/或资源的重用。
空间局部性是指在相对较接近的存储空间内数据元素的重用。
现代的CPU架构使用大容量缓存来优化具有良好空间局部性和时间局部性的应用程序。设计高效利用CPU缓存的算法是程序员的工作。程序员必须处理低层的缓存优化,但由于线程在底层架构中的安排是透明的,所以这一点程序员是没有办法优化的。
CUDA中有内存层次和线程层次的概念,使用如下结构,有助于你对线程执行进行更高层次的控制和调度:
例如,在CUDA编程模型中使用的共享内存(一个特殊的内存)。共享内存可以视为一个被软件管理的高速缓存,通过为主内存节省带宽来大幅度提高运行速度。有了共享内存,你可以直接控制代码的数据局部性。
当用ANSI C语言编写一个并行程序时,你需要使用pthreads或者OpenMP来显式地组织线程,这两项技术使得在大多数处理器架构以及操作系统中支持并行编程。当用CUDA C编写程序时,实际上你只编写了被单个线程调用的一小段串行代码。GPU处理这个内核函数,然后通过启动成千上万个线程来实现并行化,所有的线程都执行相同的计算。
CUDA编程模型提供了一个层次化地组织线程的方法,它直接影响到线程在GPU上的执行顺序。
因为CUDA C是C语言的扩展,通常可以直接将C程序移植到CUDA C程序中。概念上,剥离代码中的循环后产生CUDA C实现的内核代码。
CUDA抽象了硬件细节,且不需要将应用程序映射到传统图形API上。
CUDA核中有3个关键抽象:线程组的层次结构,内存的层次结构以及障碍同步。这3个抽象是最小的一组语言扩展。随着CUDA版本的更新,NVIDIA正在对并行编程进行不断简化。尽管一些人仍然认为CUDA的概念比较低级,但如果稍稍提高抽象级,对你控制应用程序和平台之间的互动关系来说会增加很大难度。如果那样的话,不管你掌握了多少底层架构的知识,你的应用程序的性能都将超出控制。
因此,你的目标应是学习GPU架构的基础及掌握CUDA开发工具和环境。
NVIDIA为C和C++开发人员提供了综合的开发环境以创建GPU加速应用程序,包括以下几种。
·NVIDIA Nsight集成开发环境
·CUDA-GDB命令行调试器
·用于性能分析的可视化和命令行分析器
·CUDA-MEMCHECK内存分析器
·GPU设备管理工具