第5章 线程结构 摘录

5.2 线程

线程是并行程序的基本构建块。CUDA的编程模型将线程组合在一起形成了线程束、线程块以及线程网格。

5.2.1 问题分解

CPU领域的并行化是向着一个CPU上执行不止(单一线程)程序的方向发展。但这只是我们之前所提的任务并行。如果程序具有比较密集的数据集,我们可以采用数据并行模式,将任务分解成N个,每个部分单独处理,其中,N代表可使用的CPU核数。

当只有数量较少的强劲设备时,例如CPU上,中心议题是解决平均分配工作量的问题。但如果像GPU那样拥有大量较小设备时,尽管也能很好的平均工作量,但需要花费大量的精力在同步和协调上。

并行也有粗粒度和细粒度的并行。然而,只有在那些支持大量线程的设备上才能真正实现细粒度的并行。CPU与GPU不同,它遵循多指令多数据(MIMD)模型,即它可以支持多个独立的指令流。这是一种更加灵活的方式,但由于这种方式是获取多个独立的指令流,而不是平摊多个处理器的单指令流,因此它会带来额外的开销。

5.2.2 CPU与GPU的不同

CPU的设计是用于运行少量比较复杂的任务。GPU的设计则是来运行大量比较简单的任务。CPU的设计主要是针对执行大量离散而不相关任务的系统。而GPU的设计主要是针对解决那些可以分解成成千上万个小块并可独立运行的问题。因此CPU适合运行操作系统和应用程序软件。

CPU与GPU支持线程的方式不同。CPU的每个核只有少量的寄存器,每个寄存器都将在执行任何已分配的任务中被用到。为了能执行不同的任务,CPU将在任务与任务之间进行快速的上下文切换。从时间的角度来看,CPU上下文切换的代价是非常昂贵的,因为每一次上下文切换都要将寄存器组里的数据保存到DRAM中,等到重新执行这个任务时,又从DRAM中恢复。相比之下,GPU同样用到上下文切换这个概念,但它拥有多个寄存器组而不是单个寄存器组。因此,一次上下文切换只需要设置一个寄存器组,用于将当前寄存器组里的内容换进、换出,它的速度比将数据保存到DRAM中要快好几个数量级。

CPU和GPU都需要处理失速状态。这种现象通常是有I/O操作和内存获取引起的。CPU在上下文切换的时候会这种现象。CPU的调度策略是基于时间分片,将时间平均分配给每个线程。一旦线程的数量增加,上下文切换的时间百分比就会增加,那么效率就会急剧的下降。

GPU就是专门设计用来处理这种失速状态,并且预计这种现象会经常发生。GPU采用的是数据并行的模式,它需要成千上万的线程,从而实现高效的工作。它利用有效的工作池来保证一直有事可做,不会出现闲置状态。因此,当GPU遇到内存获取操作或在等待计算结果时,流处理器就会切换另一个指令流,而在之后在执行之前被阻塞的指令。

CPU与GPU的一个主要差别就是每台设备上处理器数量的巨大差异。GPU中的SM可看作是CPU的一个核。CPU通常运行的是单线程的程序,即它的每个核的每次迭代仅计算一个数据。然而,GPU默认就是并行的模式,它的SM每次可同时计算32个数而不是像CPU那样只计算一个数。不过,CPU也可以使用像MMX、SSE和AVX那样的指令扩展集。

GPU为每个SM提供了唯一并且高速的存储器,即共享内存。它为设备提供了在标准寄存器文件之外的本地工作区。自此,程序员可以安心地将数据留在内存中,不必担心由于上下文切换操作需要将数据移出去。另外,共享内存也为线程之间的通讯提供了重要机制。

5.2.3 任务并行模式

任务执行的模式主要有两种。一种基于锁步(lock-step)思想,执行N个SP组,每个SP都执行数据不同的相同程序。另一种是利用巨大的寄存器文件,使线程的切换高效并且达到零负载。GPU能支持大量的线程就是按照寄存器文件实现的。

GPU所用的SPMD模式是将同一条指令送到N个逻辑执行单元,也就是说GPU只需要相对传统的处理器1/N的指令内存带宽。

当N个线程执行相同的控制流时,如果程序未遵循整齐的执行流,对于每一个分支而言,将会增加额外的执行周期。

5.2.4 GPU线程

void some_func(float* a)
{
    for (int i = 0; i < 128; ++i)
        a[i] = i;
}

由于上述循环中每一轮计算与下一轮计算之间没有依赖,可以并行化。

在CUDA中,你可以通过创建一个内核函数的方式将循环并行化。所谓内核函数,就是一个只能在GPU上执行而不能直接在CPU上执行的函数。按照CUDA的编程模式,CPU将主要处理它所擅长的串行代码。当遇到密集计算的代码块时,CPU则将任务交给GPU,让GPU利用它的超强的计算能力来完成密集计算。

下面代码则是一个内核函数:

__global__ void some_kernal_func(float* a, float* b, float* c)
{
    a[i] = b[i] + c[i];
}

__global__前缀是告诉编译器在编译该函数的时候生成GPU代码而不是CPU代码,并且这段GPU代码是在CPU上全局可见的。

CPU和GPU有各自独立的内存空间,因此在GPU代码中,不可以直接访问CPU端参数,反之在CPU代码中,也不能直接访问GPU端的参数。所以我们必须在GPU端的内存也声明这几个数组,然后将数据从CPU端复制到GPU端,以GPU内存指针的方式传递给GPU的内存空间进行读写操作,在计算完毕后,再将计算的结果复制回CPU端。

针对for循环中的循环控制变量,CUDA提供了一个特殊的变量,它在每个线程中的值都不一样,使得它可以标识每一个线程。这就是线程的索引,即线程ID。

线程的信息是由一个结构体threadIdx存储的。

在OpenMP和MPI中,对一个给定的循环迭代,将线程标号或线程优先级提取出来并分配给每一个线程,然后在数据集中作为下标使用。

5.2.5 硬件初窥

事实上,cuda线程都是以32个一组,当所有32线程都在等待诸如内存读取这样的操作时,它们就会被挂起。术语上,这些线程组叫做线程束(32个线程)或半个线程束(16个线程)。

当连续的线程发出读取内存的指令时,读取操作会被合并或组合在一起执行。由于硬件在管理请求时会产生一定的开销,因此这样做将减少延迟(响应请求的时间)。由于合并,内存读取会返回整组线程所需要的的数据,一般可以返回整个线程束所需要的数据。

当达到某个时间点之后,GPU将从存储子系统返回一个一个内存块序列,并且这个序列的顺序通常与发出请求的顺序是一致的。

5.2.6 CUDA内核

kernel_func<<>>(arguments list);

参数grid代表了线程网格。

参数block代表着线程块。

内核调用的下一部分是参数的传递。我们可以通过寄存器或常量内存来进行参数传递。如果使用寄存器传参,每个线程使用一个寄存器来传递一个参数。

5.3 线程块

若SM每次最多执行1536个线程。如果对于GB级、TB级甚至PB级的大规模数据。对于这类问题,这里提供多个解决方案。我们通常会选用一个线程处理多个元素或者使用线程块的其他维度来处理。

5.4 线程网格

一个线程网格是由若干个线程块组成的。

为了防止不合理的内存合并,我们要尽量做到内存的分布和线程的分布达到一一映射的关系。

在程序中,要尽量避免使用小的线程块,因为这样做无法充分利用硬件。通常192是我们所考虑的最少的线程数目。

5.4.1 跨幅与偏移

地址的计算一般是(行号 * 数组元素大小 * 数组宽度 + 数组元素大小 * 偏移量)。一般对于多维数组下标计算时,为了优化,才采取这种方式。

在对数组进行布局的时候,有一点需要我们特别注意,那就是数组的宽度值最好是线程束的大小的整数倍。如果不是,填补数组,使它能充满最后一个线程束。尽管会增加数据集的大小。此外,还需要注意对填补单元的处理,它和数组中其他单元的处理是不同的。我们可以在程序的执行流中使用填充后的跨度,分支结构(if语句),或者可以在填补单元计算完毕之后再舍弃它们的计算结果。

5.4.2 X和Y方向的线程索引

const unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;

可以选择数组与线程块上的线程形成一一映射的关系,也可以像方块一样的布局。条状或方块。

我们为什么选择长方形的布局而不是正方形的布局呢?主要有两个原因:
        1. 同一个线程块中的线程可以通过共享内存通信,这是线程协作中一种比较快的方式。
        2.同一线程束中的线程存储访问合并在一起了。

dim3是CUDA中一个数据结构,我们可以用它来创建多维线程块或线程网格。

gridDim.x/y/z;
blockDim.x/y/z;

unsigned int ix = threadIdx.x + blockIdx.x * blockDim.x;

unsigned int stripe = gridDim.x * blockDim.x;

for (int i = ix; i < 1e10; ix += stripe){}

跨越线程网格的索引。

5.5 线程束

线程束是GPU的基本执行单元。GPU是一组SIMD向量处理器的集合。每一组线程或每个线程束中的线程同时执行。在理想情况下,获得当前指令只需要一次访存,然后将指令广播到这个线程束所占用的所有SP中。

在理论上,我们可以根据核的数目划分存储带宽,但指令的吞吐量的效率会下降。而事实上,如果程序的数据都能放入缓存,CPU的片上多级缓存可以有效地隐藏由内存读取带来的延迟。

当使用GPU进行编程时,必须使用向量类型指令,因为GPU采用的是向量体系结构,只有让代码在成千上万个线程上运行才能充分高效利用GPU的资源。

当前GPU上的一个线程束的大小为32,wrapSize。

5.5.1 分支

我们之所以如此关注线程束的大小,一个很重要的原因就是分支。一个线程束是一个单独的执行单元,使用分支(if,else,for, while,do, switch等语句)可以产生不同的执行流。GPU在执行分支结构的一个分支后会接着执行另一个分支。对不满足分支条件的线程,GPU在执行这块代码的时候会将它们设置为未激活状态。当这块代码执行完毕后,GPU继续执行另一个分支,这时,满足当前的分支条件的线程将会被激活,然后执行这一段代码,最后,所有的线程聚合,继续向下执行。具体代码如下:

__global__ some_func(void)
{
    if (some_condition) {action_a();}
    else {action_b();}    
}

事实上,在指令执行层,硬件的调度是基于半个线程束,而不是整个线程束。这意味着,只要我们能将半个线程束中连续的16个线程划分到同一个分支中,那么硬件就能同时执行分支结构的两个不同条件的分支块。

如果需要让数据进行两种不同类型的处理,那么我们可以将数据以16为分界线进行划分,这样提升性能。

5.5.2 GPU利用率

我们关注线程束的另一个原因就是防止GPU未被充分利用。CUDA的模式用成千上万的线程来隐藏内存操作的延迟(从发出存储请求到完成访存操作所花的时间)。

通过观察不同计算能力的硬件,选出每个达到100%利用率最少所需的线程数,将这个线程数设为这个值,也能高效利用硬件。

SM容纳线程块的数目会受到内核中是否用到同步的影响。而所谓的同步,就是当程序的线程运行到某个点之时,运行到该点的线程需要等待其他还未运行到该点的线程,只有当所有的线程都运行到这个点时,程序才能继续往下执行。

SM一次能调度的线程束与计算能力相关。分别是24, 48, 3.0 之后的64。每个SM能容纳的线程块数目2048 / 线程块维度。

由此可见,每个线程块开启的线程数越多,就潜在的增加了等待执行比较慢的线程束的可能性。因为当所有的线程没有到达同步点时,GPU是无法继续向下执行的。因此,有时候我们会选择在每个线程块上开启较少的线程(同步时)。

5.6 线程块的调度

目前nvidia并没有公布他们使用分块还是循环还是循环-分块调度方式。可能是循环方式,因为这样容易让SM达到负载平衡。

由于线程块都是相同的大小,因此一个线程块从SM中撤出后另一个线程块在等待队列中线程块就会被调度。所有线程块的执行顺序是随机、不确定的。

在GPU上,由于线程块的不确定调度,多次对相同数据进行计算,可能由于浮点数的舍入误差,每次得到的结果可能会有些许差异。但都是正确的。

线程块的数目都是SM数目的整数倍,以此提高设备的利用率。

从负载平衡的角度而言,这个问题还有待优化。因此,在之后的CUDA运行时库中重叠的内核已经在同一块CUDA设备上可以运行多个单独的内核。通过这种方法,我们就可以维持吞吐量,使GPU集群布置一任务员源可以调度。一旦设备出现闲置,它就会从内核流中选择另一个内核进行执行。

5.7 一个实例--统计直方图

0~256个数,数组里面的值是多少,那么相应的bin就加1。

for (unsigned int i = 0; i < max; i++)
{ bin[array[i]]++}

该代码汇编之后的操作分为以下步骤:
        1. 从输入数组中读取数据到寄存器;
        2. 计算出这个数对应的bin的基地址与偏移量;
        3. 获取当前这个数对应的bin值;
        4. 对bin值进行加1;
        5. 将新的bin值写回内存;

问题出在步骤3,4,5。因为他们没有进行原子操作。所谓的原子操作,就是当某个线程对某项数据进行修改时候,其他优先级比较低的线程无法打断它的操作,直到该线程完成对数据的所有操作。

数据的相关性造成了这个问题的产生,而在用顺序执行的代码我们根本看不到这个问题。bin在线程之间是以共享资源的形式存在,因此,在某个线程读取和修改bin值,必须等上一个线程完成对bin的操作才行。

atomicadd(&value);

但是这种简陋的方法获得的性能很低。

// 由于加操作全部交给原子操作,所以性能特别差
__global__ void myHistogram256Kernel_01(const unsigned char const* d_hist_data )
{
	const unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
	const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;
	const unsigned int tid = idx + idy * blockDim.x * gridDim.x;

	const unsigned char value = d_hist_data[tid];

	atomicAdd(&(d_hist_data[value]), 1);

}

// 由于char只占一个字节,半个线程束只读了16个字节,而最好的情况下,半个线程束能读取128个字节,故内存带宽被浪费了
__global__ void myHistogram256Kernel_02(const unsigned char const* d_hist_data)
{
	const unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
	const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;
	const unsigned int tid = idx + idy * blockDim.x * gridDim.x;

	const unsigned char value_u32 = d_hist_data[tid];

	// & 0x000000FF >> 8/16/24 能按照读取整型数一样每次读取4个字节,然后将这个整型数拆分为4个来进行计算。
	// 而不是读取之前的读取1个字节
	atomicAdd(&(d_hist_data[((value_u32 & 0x000000FF))]), 1);
	atomicAdd(&(d_hist_data[((value_u32 & 0x0000FF00) >> 8)]), 1);
	atomicAdd(&(d_hist_data[((value_u32 & 0x00FF0000) >> 16)]), 1);
	atomicAdd(&(d_hist_data[((value_u32 & 0xFF000000) >> 24)]), 1);
}

// 但是在此kernel里面,存储带宽带来的微小影响,原子写操作才可能是性能瓶颈的罪魁祸首。
// 所以基于数据分解模型编写内核。另外,发现内核中一些数据会重用,故将再次利用的数据放入高效缓存内。

// 另一种方法是让每个SM都计算出一个统计直方图,最后将所有的直方图汇总到一块主内存内。在共享内存上创建一个包含
// 256个bin的局部统计直方图,最后将所有共享内存上计算得到的统计直方图通过原子操作汇总到全局内存。但此时面对全局内存
// 的读写操作次数也不会因此减少,但写回内存的操作却因此可以合并起来。

__shared__ unsigned int d_bin_data_shared[256];

__global__ void myHistogram256Kernel_03(const unsigned char const* d_hist_data, unsigned int* d_bin_data)
{
	const unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
	const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;
	const unsigned int tid = idx + idy * blockDim.x * gridDim.x;

	/*清空共享内存原始数据*/
	d_bin_data_shared[threadIdx.x] = 0;

	const unsigned char value_u32 = d_hist_data[tid];

	/*等待所有线程更新完共享内存*/
	__syncthreads();

	atomicAdd(&(d_bin_data_shared[((value_u32 & 0x000000FF))]), 1);
	atomicAdd(&(d_bin_data_shared[((value_u32 & 0x0000FF00) >> 8)]), 1);
	atomicAdd(&(d_bin_data_shared[((value_u32 & 0x00FF0000) >> 16)]), 1);
	atomicAdd(&(d_bin_data_shared[((value_u32 & 0xFF000000) >> 24)]), 1);

	/*等待所有线程更新完共享内存*/
	__syncthreads();

						// 此处已经将写操作合并起来了
	atomicAdd(&(d_bin_data[threadIdx.x]), d_bin_data_shared[threadIdx.x]);
}

// 将连续的写操作合并起来之后,我们需要考虑一下如何减少全局内存的阻塞。我们已经对读数据进行优化,每次从源数据中读出一个值,
// 而且每个值只需要读一次,因此,我们只需要考虑减少对全局写操作的次数。假设每个线程块处理的直方图不是一个,而是N个,那么我们对全局内存的
// 写操作的带宽就会减少N倍数。
__global__ void myHistogram256Kernel_04(const unsigned char const* d_hist_data, unsigned int* d_bin_data, unsigned int N)
{
	const unsigned int idx = blockIdx.x * blockDim.x * N + threadIdx.x;
	const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;
	const unsigned int tid = idx + idy * blockDim.x * N * gridDim.x;

	/*清空共享内存原始数据*/
	d_bin_data_shared[threadIdx.x] = 0;


	/*等待所有线程更新完共享内存*/
	__syncthreads();

	for (unsigned int i = 0, tid_offset = 0; i < N; ++i, tid_offset += 256)
	{
		const unsigned char value_u32 = d_hist_data[tid + tid_offset];

		atomicAdd(&(d_bin_data_shared[((value_u32 & 0x000000FF))]), 1);
		atomicAdd(&(d_bin_data_shared[((value_u32 & 0x0000FF00) >> 8)]), 1);
		atomicAdd(&(d_bin_data_shared[((value_u32 & 0x00FF0000) >> 16)]), 1);
		atomicAdd(&(d_bin_data_shared[((value_u32 & 0xFF000000) >> 24)]), 1);
	}


	/*等待所有线程更新完共享内存*/
	__syncthreads();

	// 此处已经将写操作合并起来了
	atomicAdd(&(d_bin_data[threadIdx.x]), d_bin_data_shared[threadIdx.x]);
}

// 但是上述kernel还是因为有原子操作,每个线程都要同其他线程一同对一块共享数据区域进行竞争,又由于
// 数据模式设计的并不好,因此,对执行中的时间有了很大的影响。

由于自始至终都是用了原子操作,因此只需要在内核计算的开始与结尾处进行同步操作。不必要的同步会降低程序的性能,但同时也能让内存的访问变得更加整齐统一。

5.8 本章小结

利用CUDA将任务分解到线程网络、线程块及线程上。

硬件上的线程束的概念以及线程块的调度问题,以及时刻保证硬件上有足够数量的线程的需要。

根据待处理的数据来组织线程结构是非常重要的。

原子操作以及原子操作带来的序列化执行问题。另外还有分支结构带来的问题,要牢记保证所有线程遵循相同控制路径的重要性。

你可能感兴趣的:(CUDA,编程指南,Shane,Cook,c++,cuda,并行计算)