CUDA编程之二——编程模型


CUDA编程模型

上一篇文章我们对GPU和CUDA的概念进行了相关介绍。尝试梳理出GPU和CUDA引入的动机,解决了Why和What的问题,接下来这边文章开始,我们开始进入CUDA编程核心技术的学习,也就是要弄明白How to implement?

闲言少叙,我们正式开始吧。

俗话说,万丈高楼平地起,我们盖房子必须要先搭建好房屋的框架和整体结构,在此基础上进一步去完善。对于CUDA的学习也是这样,因此我们首先介绍CUDA编程的模型,即CUDA房屋的四梁八柱整体架构。

首先我们思考一个问题:CUDA编程,它的目的是什么?想要达到什么效果,在为了实现这个效果的过程中,我需要进行哪些操作,这些操作的主体和客体又是什么,尝试去弄懂这些问题,其实就对我们的CUDA编程模型有了一个大概的了解了。

首先我们考虑第一个问题,CUDA编程的目的是什么?

由第一节我们介绍的内容可知,引入CUDA的Motivation其实就是为了研究人员能够更方便、更充分的利用GPU的并行计算能力进行科学计算。这也就是我们CUDA编程的目的,但这里可能有小伙伴会说了,我也没有进行CUDA编程,那我在使用Pytorch等进行神经网络的搭建、训练时训练的也挺快的呀,其实原因是因为Pytorch等框架已经将相关的卷积等运算进行了CUDA加速,封装好了供我们使用。 但如果你有某些特殊的需求或者你提出了某个卷积还没有被Pytorch支持,或者你觉得pytroch做的不够好,你想自己进行CUDA加速,这时候就需要我们自己进行CUDA编程来实现了。

为了实现上述目标,我们就需要进行CUDA编程,我们先要搞清楚CUDA编程是个笼统的概念,本质上就是利用编程语言操作计算机完成一系列我们想要它完成的内容。 这里我们可以思考一下,对于CUDA而言,上面我们提到的操作,它的主体和客体分别是什么呢?

这里操作的主体指的是执行操作的实体。而客体就是被操作的(即被主题操作)的实体。

具体到CUDA编程而言: 主体其实指的就是

  1. 主机(Host)如CPU及其上的内存等。
  2. 设备(Device)在这里指的就是GPU。
  3. 我们编写的CUDA程序(执行操作的指令和逻辑)

客体指的是:

  1. GPU上的内存对象,包括全局内存、共享内存、常量内存、纹理内存等,存储在GPU上,被核函数直接访问。主机内存中的数据通常需要传输到GPU内存中,以供核函数处理。
  2. 核函数(Kernels):用户定义的CUDA函数,当被调用时在GPU上并行执行。核函数是并行计算任务的具体实现,通常执行诸如数据处理、计算等操作。
  3. 数据结构和算法,程序从GPU内存中读取的数据需要以恰当的结构进行组织,方便计算,提高效率。

总结上述操作和主体、客体,我们就明白了cuda编程核心其实就是有效的将复杂的计算任务分解为可以在GPU上并行执行的核函数,同时对GPU内存、CPU内存以及GPU和CPU上的内存进行高效的管理。优化数据传输和内存使用。

为了实现上述的核心诉求,我们就要复习第一节提到的几个概念: 1、核函数:作为CUDA编程编程中的基本单位,它用于定义在GPU上并行指定的操作。

2、线程:作为执行计算的最小单位。

3、流处理器:GPU上的物理单元,负责执行CUDA线程。

在CUDA编程模型中,核函数定义了在GPU上执行的操作,线程是这些操作的执行单元,而流多处理器(Streaming Multiprocessors)则是实际执行这些线程的物理硬件。

我们的编程模型基于上述三个概念进行展开: 主要包括以下几个部分:

  1. 核函数
  2. 线程与线程块
  3. 内存管理
  4. 异构编程模型(也可以理解为对不同设备的管理使用)(Henterogeneous Programming)
  5. 异步编程模型(Asynchronous SIMT Programming Model)

核函数(Kernels)

作为C++中函数的扩展,CUDA C++ 使用核函数的概念定义操作,当被调用时,可以在多个并行线程上被执行。 区别于C++中的函数:核函数使用 __global__ 进行声明, 使用<<<...>>>进行调用。

线程组织

由于线程是执行并行计算任务的最小单元,核函数的执行任务其实就是落在了每一个线程的肩上,因此要更好的完成并行计算的任务,我们就要更好的对线程进行组织。

线程(thread)作为最底的层次,它每次执行核函数的一个实力,而为了更充分的利用硬件,它可以被组织成线程块(thread block),线程块内的线程可以通过共享内存进行协作,同一线程块内的线程被分配到一个流处理器(SM)上进行执行。

网格(grid)又较线程块高一等级,多个线程块组成一个网格。一个核函数的调用会在一个网格上执行,网格中的每个线程块可以在不同的流处理器(SM)上执行。

内存管理

CUDA 线程在执行期间可以访问多个内存空间中的数据,如图所示。每个线程都有私有本地内存。 每个线程块都有对该块的所有线程可见的共享内存,并且与该块具有相同的生命周期。 线程块簇中的线程块可以对彼此的共享内存执行读、写和原子操作。 所有线程都可以访问相同的全局内存。

还有两个可供所有线程访问的附加只读内存空间:常量内存空间和纹理内存空间。 全局、常量和纹理内存空间针对不同的内存使用情况进行了优化(请参阅设备内存访问)。 纹理内存还为某些特定的数据格式提供不同的寻址模式以及数据过滤(请参阅纹理和表面内存)。

全局、常量和纹理内存空间在同一应用程序的内核启动过程中是持久的。 memory-hierarchy

异构编程

heterogenous 异构系统的定义

物理分离的设备:CUDA 编程模型基于异构计算的概念,即 CUDA 线程在一个物理上与主机(通常是 CPU)分离的设备(如 GPU)上执行。

协处理器:在这种模型中,GPU 作为一个协处理器来辅助主机 CPU,专门执行计算密集型的任务。

内存管理

独立的内存空间:主机(CPU)和设备(GPU)各自维护着独立的内存空间。这意味着在 CPU 的内存(主机内存)和 GPU 的内存(设备内存)之间不是共享的。

内存操作:CUDA 提供了一套运行时 API 来管理设备内存,包括内存的分配和释放,以及主机和设备之间的数据传输。

统一内存(Unified Memory)

作用:统一内存是一个内存管理模型,它为主机和设备提供了一个单一、一致的内存视图,简化了数据在 CPU 和 GPU 之间的共享和传输。

优势:使用统一内存,开发者不再需要手动管理两种不同的内存空间和数据传输,这简化了程序开发,特别是在处理复杂的数据结构时。

应用场景:它特别适用于需要频繁在 CPU 和 GPU 之间传输数据的应用,或者那些内存使用需求超过 GPU 单独内存容量的情况。

举例说明

为了更好地理解这一概念,考虑一个典型的 CUDA 应用,如图像处理或数值模拟:

主机端:CPU 负责处理程序的通用部分,如用户输入、文件 I/O 和控制逻辑。

设备端:GPU 执行计算密集型任务,如图像的像素操作或数值计算。

数据传输:必须在 CPU 准备数据后将其传输到 GPU,GPU 处理完成后再将结果传回 CPU。

统一内存的使用:通过使用统一内存,这些数据传输步骤可以被简化,因为统一内存自动处理数据在 CPU 和 GPU 之间的迁移。 总之,CUDA 的异构编程模型允许开发者充分利用 CPU 和 GPU 的各自优势,而统一内存则简化了数据在这两种处理器之间的管理和迁移,从而提高了编程效率和应用性能。

异步编程

定义和背景

异步编程模型:在 NVIDIA Ampere GPU 架构中,CUDA 引入了对内存操作的异步编程模型。这意味着某些操作(如内存复制)可以在不阻塞其他 CUDA 线程的情况下执行。

线程与异步操作:在这个模型中,一个 CUDA 线程可以启动一个异步操作,该操作会像由另一个线程执行一样进行。

同步对象:异步操作通常会使用同步对象(如 cuda::barrier 或 cuda::pipeline)来同步操作的完成。这些对象可以在不同的线程范围内使用,以控制哪些线程与异步操作同步。

异步操作的应用

cuda::memcpy_async:这是一个异步内存复制操作的例子,允许数据在 GPU 计算的同时从全局内存异步地移动。

同步点:在程序中,开发者可以设置同步点,确保异步操作在继续执行之前已经完成。