1_0 并行计算与计算机架构
并行计算其实设计到两个不同的技术领域:
- 计算机架构(硬件):生产工具
- 并行程序设计(软件):用工具产生各种不同应用
1.1 并行性
写并行程序主要是分解任务,一般把一个程序看成是指令和数据的组合,当然并行也可以分为这两种:
- 指令并行
- 数据并行
我们的任务更加关注数据并行。
任务并行多出现在各种管理系统,比如我们天天用的支付系统,基本上每时每刻都有很多人在同时使用,这时候就需要后台的处理能够并行执行这些请求,不然全国人民排队,那就比春运还热闹了。
1.1.1 把数据依据线程进行划分
数据并行程序设计,第一步就是把数据依据线程进行划分:
- 块划分,把一整块数据切成小块,每个小块随机的划分给一个线程,每个块的执行顺序随机(关于线程的概念可以去看《深入理解计算机系统》)
thread | 1 | 2 | 3 | 4 | 5 |
block | 1 2 3 | 4 5 6 | 7 8 9 | 10 11 12 | 13 14 15 |
- 周期划分,线程按照顺序处理相邻的数据块,每个线程处理多个数据块,比如我们有五个线程,线程1执行块1,线程2执行块2……线程5执行块5,线程1执行块6
thread | 1 | 2 | 3 | 4 | 5 | 1 | 2 | 3 | 4 | 5 | 1 | 2 | 3 | 4 | 5 |
block | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
1.2 计算机架构
1.2.1 Flynn’s Taxonomy
划分不同计算机结构的方法有很多,广泛使用的一种被称为佛林分类法Flynn’s Taxonomy,他根据指令和数据进入CPU的方式分类,分为以下四类:
分别以数据和指令进行分析:
- 单指令单数据SISD(传统串行计算机,386)
- 单指令多数据SIMD(并行架构,比如向量机,所有核心指令唯一,但是数据不同,现在CPU基本都有这类的向量指令)
- 多指令单数据MISD(少见,多个指令围殴一个数据)
- 多指令多数据MIMD(并行架构,多核心,多指令,异步处理多个数据流,从而实现空间上的并行,MIMD多数情况下包含SIMD,就是MIMD有很多计算核,计算核支持SIMD)
为了提高并行的计算能力,我们要从架构上实现下面这些性能提升:
- 降低延迟:是指操作从开始到结束所需要的时间,一般用微秒计算,延迟越低越好。
- 提高带宽:带宽是单位时间内处理的数据量,一般用MB/s或者GB/s表示。
- 提高吞吐量:是单位时间内成功处理的运算数量,一般用gflops来表示(十亿次浮点计算)。
吞吐量和延迟有一定关系,都是反应计算速度的:
- 一个是时间除以运算次数,得到的是单位次数用的时间–延迟;
- 一个是运算次数除以时间,得到的是单位时间执行次数–吞吐量。
1.2.2 根据内存划分
计算机架构也可以根据内存进行划分:
- 分布式内存的多节点系统:更大,通常叫做集群,就是一个机房好多机箱,每个机箱都有内存处理器电源等一些列硬件,通过网络互动,这样组成的就是分布式。
- 共享内存的多处理器系统:单个主板有多个处理器,他们共享相同的主板上的内存,内存寻址空间相同,通过PCIe和内存互动。
注意:多个处理器可以分多片处理器,和单片多核(众核many-core),也就是有些主板上挂了好多片处理器,也有的是一个主板上就一个处理器,但是这个处理器里面有几百个核。
最后:
- CPU适合执行复杂的逻辑,比如多分支,其核心比较重(复杂)
- GPU适合执行简单的逻辑,大量的数据计算,其吞吐量更高,但是核心比较轻(结构简单)
1_1 异构计算与CUDA
Abstract: 介绍异构计算和CUDA概述,完成GPU输出Hello world!
Keywords: 异构计算,CUDA
1.1 异构计算
同构计算:是使用相同类型指令集和体系架构的计算单元组成系统的计算方式。
异构计算:主要是指使用不同类型指令集和体系架构的计算单元组成系统的计算方式,常见的计算单元类别包括CPU、GPU、DSP、ASIC、FPGA等。
异构计算用简单的公式可以表示为“CPU+XXX”。
由于术业有专攻,CPU、GPU、DSP、ASIC、FPGA各有所长,在一些场景下,引入特定计算单元,让计算系统变成混合结构,就能让CPU、GPU、DSP、FPGA执行自己最擅长的任务。
GPU原本是不可编程的,或者说不对用户开放的,然后就有hacker开始想办法给GPU编程,来帮助他们完成规模较大的运算,于是他们研究着色语言或者图形处理原语来和GPU对话。后来黄老板发现了这个是个新的功能啊,然后就让人开发了一套平台,CUDA,然后深度学习火了,顺带着,CUDA也火到爆炸。
1.2 异构架构
上面这张图能大致反应CPU和GPU的架构不同。
- 左图:一个四核CPU一般有四个ALU,ALU是完成逻辑计算的核心,也是我们平时说四核八核的核,控制单元,缓存也在片上,DRAM是内存,一般不在片上,CPU通过总线访问内存。
- 右图:GPU,绿色小方块是ALU,我们注意红色框内的部分SM,这一组ALU公用一个Control单元和Cache,这个部分相当于一个完整的多核CPU,但是不同的是ALU多了,control部分变小,可见计算能力提升了,控制能力减弱了,所以对于控制(逻辑)复杂的程序,一个GPU的SM是没办法和CPU比较的,但是对于逻辑简单,数据量大的任务,GPU更高效,并且,注意,一个GPU有好多个SM,而且越来越多。
一个异构应用包含两种以上架构,所以代码也包括不止一部分:
- 主机代码:在主机端运行,被编译成主机架构的机器码;
- 主机端代码主要是控制设备,完成数据传输等控制类工作,
- 设备代码:设备端的在设备上执行,被编译成设备架构的机器码;
- 设备端主要的任务就是计算。
所以主机端的机器码和设备端的机器码是隔离的,自己执行自己的,没办法交换执行。
1.3 【函数】cudaDeviceReset()
/*
*hello_world.cu
*/
#include<stdio.h>
__global__ void hello_world(void)
{
printf("GPU: Hello world!\n");
}
int main(int argc,char **argv)
{
printf("CPU: Hello world!\n");
hello_world<<<1,10>>>();
cudaDeviceReset();//if no this line ,it can not output hello world from gpu
return 0;
}
如果没有cudaDeviceReset()
,则不能正常的运行。
答:这句话包含了隐式同步,GPU和CPU执行程序是异步的,核函数调用后成立刻会到主机线程继续,而不管GPU端核函数是否执行完毕,所以上面的程序就是GPU刚开始执行,CPU已经退出程序了,所以我们要等GPU执行完了,再退出主机线程。
1.4 CUDA程序一般步骤
一般CUDA程序分成下面这些步骤:
- 分配host内存,并进行数据初始化;
- 分配device内存,并从host将数据拷贝到device上;
- 调用CUDA的核函数在device上完成指定的运算;
- 将device上的运算结果拷贝到host上(性能)
- 释放device和host上分配的内存。
2_0 CUDA编程模型概述(一)
Abstract: 本文介绍CUDA编程模型的简要结构,包括写一个简单的可执行的CUDA程序,一个正确的CUDA核函数,以及相应的调整设置内存,线程来正确的运行程序。
Keywords: CUDA编程模型,CUDA编程结构,内存管理,线程管理,CUDA核函数,CUDA错误处理
1.1 CUDA编程模型概述
CUDA编程模型为应用和硬件设备之间的桥梁,所以CUDA C是编译型语言,不是解释型语言。
GPU中大致可以分为:
- 核函数
- 内存管理
- 线程管理
- 流
从宏观上我们可以从以下几个环节完成CUDA应用开发:
- 领域层:在领域层(也就是你所要解决问题的条件)分析数据和函数,以便在并行运行环境中能正确,高效地解决问题。
- 逻辑层:分析设计完程序就进入了编程阶段,我们关注点应转向如何组织并发进程,这个阶段要从逻辑层面思考。
- 硬件层:通过理解线程如何映射到机器上,能充分帮助我们提高性能。
1.2 CUDA编程结构
一个异构环境,通常有多个CPU多个GPU,他们都通过PCIe总线相互通信,也是通过PCIe总线分隔开的。所以我们要区分一下两种设备的内存:
- 主机:CPU及其内存
- 设备:GPU及其内存
注意这两个内存从硬件到软件都是隔离的(CUDA6.0 以后支持统一寻址),我们目前先不研究统一寻址.
一个完整的CUDA应用可能的执行顺序如下图:
从host的串行到调用核函数(核函数被调用后控制马上归还主机线程,也就是在第一个并行代码执行时,很有可能第二段host代码已经开始同步执行了)。
1.3 内存管理
内存管理在传统串行程序是非常常见的:
- 栈空间内的内存由机器自己管理;
- 堆空间由用户控制分配和释放;
CUDA程序同样,只是CUDA提供的API可以分配管理设备上的内存,当然也可以用CDUA管理主机上的内存,主机上的传统标准库也能完成主机内存管理。
下面表格有一些主机API和CUDA C的API的对比:
标准C函数 | CUDA C 函数 | 说明 |
malloc | cudaMalloc | 内存分配 |
memcpy | cudaMemcpy | 内存复制 |
memset | cudaMemset | 内存设置 |
free | cudaFree | 释放内存 |
2_1 CUDA编程模型概述(二)
当主机启动了核函数,控制权马上回到主机,而不是主机等待设备完成核函数的运行。即:所有CUDA核函数的启动都是异步的,这点与C语言是完全不同的。
这一点我们上一篇文章也有提到过(就是等待hello world输出的那段代码后面要加一句)
1.1 限定符:__global__
/__device__
/__host__
限定符 | 执行 | 调用 | 备注 |
global | 设备端执行 | 可以从主机调用也可以从计算能力3以上的设备调用 | 必须有一个void的返回类型 |
device | 设备端执行 | 设备端调用 | |
host | 主机端执行 | 主机调用 | 可以省略 |
1.2 核函数编写时的限制
Kernel核函数编写有以下限制
- 只能访问设备内存
- 必须有void返回类型
- 不支持可变数量的参数
- 不支持静态变量
- 显示异步行为
1.3 错误处理
所有编程都需要对错误进行处理,早期的编码错误,编译器会帮我们搞定,内存错误也能观察出来,但是有些逻辑错误很难发现,甚至到了上线运行时才会被发现。
例如我们代码库头文件里面的这个宏:
#define CHECK(call)\
{\
const cudaError_t error=call;\
if(error!=cudaSuccess)\
{\
printf("ERROR: %s:%d,",__FILE__,__LINE__);\
printf("code:%d,reason:%s\n",error,cudaGetErrorString(error));\
exit(1);\
}\
}
就是获得每个函数执行后的返回结果,然后对不成功的信息加以处理,CUDA C 的API每个调用都会返回一个错误代码,这个代码我们就可以好好利用了,当然在release版本中可以去除这部分,但是开发的时候一定要有的。
1.4 nvcc编译执行
编译指令:nvcc xxxx.cu -o xxxx
2_2 给核函数计时
我们可以大概分析下核函数启动到结束的过程:
- 主机线程启动核函数
- 核函数启动成功
- 控制返回主机线程
- 核函数执行完成
- 主机同步函数侦测到核函数执行完
如下图所示:
我们要测试的是2~4的时间。
1.1 用CPU计时
主要讲了三种:
-
clock()
:对于核函数计时不准确; -
gettimeofday
:对于核函数计时较准确; -
nvprof 工具
:该工具不仅给出了kernel执行的时间,还有其他cuda函数的执行时间。三者之中,最准确。
1.1.1 使用clock()
使用cpu计时的方法是测试时间的一个常用办法,我们在写C程序的时候最多使用的计时方法是:
clock_t start, finish;
start = clock();
// 要测试的部分
finish = clock();
duration = (double)(finish - start) / CLOCKS_PER_SEC; // CLOCKS_PER_SEC这个宏,就是每秒中多少clocks,在不同的系统中值可能不同。
不准确原因:
- clock()是个关键的函数,clock函数测出来的时间为进程运行时间,单位为滴答数(ticks)。必须注意的是,并行程序这种计时方式有严重问题!如果想知道具体原因,可以查询clock的源代码(c语言标准函数)
1.1.2 使用gettimeofday()
具体使用方法:https://linuxhint.com/gettimeofday_c_language/
#include <sys/time.h>
double cpuSecond() // 例子
{
struct timeval tp;
gettimeofday(&tp,NULL);
return((double)tp.tv_sec+(double)tp.tv_usec*1e-6);
}
gettimeofday()
是linux下的一个库函数,创建一个cpu计时器,从1970年1月1日0点以来到现在的秒数,需要头文件sys/time.h
.
不准确原因:使用gettimeofday()
函数只能测试1~5的时间,所以测试得到的时间偏长。
1.2 用nvprof计时
CUDA 5.0后有一个工具叫做nvprof的命令行分析工具,(后面还要介绍一个图形化的工具)。
nvprof的用法如下:
nvprof [nvprof_args] <application>[application_args]
2_3 组织并行线程
1.1 线程ID计算
首先,需要知道block、grid三维布局中,x/y/z的先后顺序:
1.2 网格大小如何确定?
当我们确定好 线程块大小为a时,我们应该如何确定网格的尺寸呢?
答:
- 在一维中,假设我们需要启动共N个线程,线程块尺寸为a。最容易想到的是,网格尺寸为:
N/a
。但是我们需要注意,这可能除不尽(如N=10,a=3),此时算出来的是,网格尺寸为3。如果按照结果来,我们实际上只启动了3*3=9个线程。所以正确的做法是:
- ,公式可以理解为:计算 ≥N 的a的最小倍数。
- 在二维中,假设启动共(Nx * Ny)个线程,线程块尺寸为(x, y)。则其网格尺寸:
- // 即,其每一维的计算方式都是一样的。
- 三维中,以此类推。
2.4 GPU设备信息
一个命令
nvidia-smi
,也可以查询一些信息。nvidia-smi是nvidia驱动程序内带的一个工具,可以返回当前环境的设备信息。具体参数使用使用查文档。
一些函数:
cudaGetDeviceCount()
cudaGetDeviceProperties()
- 例子中,没用到的函数:
-
cudaSetDevice()
:设置GPU执行所使用的设备。 - cudaDriverGetVersion():返回驱动程序支持的CUDA的最新版本。
- cudaRuntimeGetVersion():返回CUDA运行时版本。
__host__ __device__ cudaError_t cudaGetDeviceCount ( int* count )
返回可计算设备的数量。
Parameters
- count:返回计算能力大于或等于2.0的设备数量。
Returns
__host__ cudaError_t cudaSetDevice ( int device )
设置GPU执行所使用的设备。
Parameters
- device:活动主机线程应该在其上执行设备代码的设备。
returns
查询设备步骤:
- 使用
cudaGetDeviceCount()
来获得系统中支持CUDA架构的设备数目; - 调用第一步的函数后,我们可以对每个设备进行迭代。CUDA运行时将返回一个cudaDeviceProp类型的结构(具体结构,详见),其中包含了设备的相关属性。
所以,第二步应该:
- 创建一个
cudaDeviceProp
类型的变量。如:cudaDeviceProp prop;
- 然后使用函数
cudaGetDeviceProperties ( cudaDeviceProp* prop, int device )
来获得设备信息。
- 在执行完第2步后,该设备的信息以存入变量 prop中。所以,我们可以通过该变量来获取设备具体信息。
// 询问设备信息的主要代码为:
int deviceCount;
cudaGetDeviceCount(&deviceCount);
cudaDeviceProp prop;
for(int i =0; i<count; i++)
{
cudaGetDeviceProperties(&prop, i); // 将第i个设备的信息,存入变量prop中。
// 接下来便可以通过变量访问第i个设备的信息了,如:
printf("设备名字:\n",prop.name);
.......更多属性,可查阅cudaDeviceProp类型的结构
}
3_1 CUDA执行模型概述
https://face2ai.com/CUDA-F-3-1-CUDA%E6%89%A7%E8%A1%8C%E6%A8%A1%E5%9E%8B%E6%A6%82%E8%BF%B0/
Abstract: 本文介绍CUDA执行模型,只比硬件高一层的抽象
Keywords: CUDA SM,SIMT,SIMD,Fermi,Kepler
GPU架构是围绕一个流式多处理器(SM)的扩展阵列搭建的。通过复制这种结构来实现GPU的硬件并行。
上图包括关键组件:
- CUDA核心
- 共享内存/一级缓存
- 寄存器文件
- 加载/存储单元
- 特殊功能单元
- 线程束调度器
1.1 SM
一个GPU含有多个SM;
一个SM支持数百个线程并发执行;
当一个核函数的网格被启动的时候,多个block会被同时分配给可用的SM上执行。
当一个blcok被分配给一个SM后,他就只能在这个SM上执行了,不可能重新分配到其他SM上了;
多个线程块可以被分配到同一个SM上。
SM上同一个块内的多个线程进行线程级别并行;同一线程内,指令利用指令级并行将单个线程处理成流水线。
1.2 warp(线程束)
CUDA 采用单指令多线程SIMT架构管理执行线程;
不同设备有不同的线程束大小,但是到目前为止基本所有设备都是维持在32;
- 每个SM上有多个block;一个block有多个线程(数量不能超过最大值);
- 从机器的角度,在某时刻T,SM上只执行一个线程束,即这个线程束中的线程同时同步的执行。
- 即:warp是基本的执行单元。
- warp中的每一个线程执行同一条指令,包括有分支的部分(只是有的线程可能不会执行分支部分)。
1.3 SIMD vs SIMT
虽然两者都是将相同指令广播给多个执行单元,同一时刻所有线程被分配给相同的指令,但是:
- SIMD单指令多数据:SIMD规定所有人必须执行,分支必须同时执行相同的指令,必须执行没有例外。SIMD更像是指令级别的并行。
- SIMT单指令多线程:规定有些人可以根据需要不执行,这样SIMT就保证了线程级别的并行。(如分支部分有些线程就不执行分支部分的操作)
SIMT相比于SIMD,其有以下特征:
- 每个线程都有自己的指令地址计数器;
- 每个线程都有自己的寄存器状态;
- 每个线程可以有一个独立的执行路径;
而上面这三个特性在编程模型可用的方式就是给每个线程一个唯一的标号(blckIdx,threadIdx),并且这三个特性保证了各线程之间的独立。
1.4 只有warp内的线程有相同的进度
因为SM有限,虽然我们的编程模型层面看所有线程都是并行执行的。
但是在微观上看,所有线程块也是分批次的在物理层面的机器上执行,线程块里不同的线程可能进度都不一样,但是同一个线程束内的线程拥有相同的进度。
并行就会引起竞争,多线程以未定义的顺序访问同一个数据,就导致了不可预测的行为,CUDA只提供了一种块内同步的方式,块之间没办法同步!
同一个SM上可以有不止一个常驻的线程束,有些在执行,有些在等待,他们之间状态的转换是不需要开销的。即:每个线程束在同一时间执行同一指令,同一个块内的线程束互相切换是没有时间消耗的。
1.5 Fermi架构、Kepler架构
这一小节请阅读:https://face2ai.com/CUDA-F-3-1-CUDA%E6%89%A7%E8%A1%8C%E6%A8%A1%E5%9E%8B%E6%A6%82%E8%BF%B0/#Fermi-架构
1.6 CUDA执行的顺序
这一节以Fermi架构作为硬件基础。
每个SM有两个线程束调度器,两个指令调度单元。
当一个线程块被指定给一个SM时,线程块内的所有线程被分成warp,两个线程束调度器会选择其中的两个线程束(warp),然后用指令调度器存储两个线程束要执行的指令。这些线程束在SM上交替执行。