CUDA学习日志:线程协作与例程

接触CUDA的时间并不长,最开始是在cuda-convnet的代码中接触CUDA代码,当时确实看的比较痛苦。最近得空,在图书馆借了本《GPU高性能编程 CUDA实战》来看看,同时也整理一些博客来加强学习效果。

Jeremy Lin

在上篇博文中,我们已经用CUDA C编写了一个程序,知道了如何编写在GPU上并行执行的代码。但是对于并行编程来说,最重要的一个方面就是,并行执行的各个部分如何通过相互协作来解决问题。只有在极少数情况下,各个处理器才不需要了解其他处理器的执行状态而彼此独立地计算出结果。即使对于一些成熟的算法,也仍然需要在代码的各个并行副本之间进行通信和协作。因此,下面我们来讲讲不同线程之间的通信机制和并行执行线程的同步机制。

首先,我们来看一个线程块的网格示意图:

我们将并行线程块的集合称为线程格(Grid),在上图的Grid中总共有6个线程块(block),每个线程块有12个线程(thread)

硬件限制

  • 线程块的数量限制为不超过65 535;
  • 每个线程块的线程数量限制为不超过512。

解决线程块数量的硬件限制的方法就是将线程块分解为线程。

共享内存

线程协作主要是通过共享内存实现的。CUDA C支持共享内存,我们可以将CUDA C的关键字__share__添加到变量声明中,这将使这个变量驻留在共享内存中。

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

附加知识:

变量类型限定符

__device__

该限定符声明位于设备上的变量。在接下来介绍的其他类型限定符中,最多只能有一种可与__device__限定符一起使用,以更具体地指定变量属于哪个存储器空间。如果未出现其他限定符,则变量具有以下特征:

    • 位于全局储存器空间中;
    • 与应用程序具有相同的生命周期;
    • 可通过网格内的所有线程访问,也可通过运行时库从主机访问。

__constant__

该限定符可选择与__device__限定符一起使用,所声明的变量具有以下特征:

    • 位于固定存储器空间中;
    • 与应用程序具有相同的生命周期;
    • 可通过网格内的所有线程访问,也可通过运行时库从主机访问。

__shared__

该限定符可选择与__device__限定符一起使用,所声明的变量具有以下特征:

    • 位于线程块的共享存储器空间中;
    • 与块具有相同的生命周期;
    • 仅可通过块内的所有线程访问。

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

CUDA C编译器对共享内存中的变量与普通变量将分别采取不同的处理方式。对于GPU上启动的每个线程块,CUDA C编译器都将创建该变量的一个副本。线程块中的每个线程都共享这块内存,但线程却无法看到也不能修改其他线程块的变量副本。这就实现了一种非常好的方式,使得一个线程块中的多个线程能够在计算上进行通信和协作。而且,共享内存缓冲区驻留在物理GPU上,而不是驻留在GPU之外的系统内存中。因此,在访问共享内存时的延迟要远远低于访问普通缓冲区的延迟,使得共享内存像每个线程块的高速缓存或者中间结果暂存器那样高效。

不过,如果想要真正实现线程之间的通信,还需要一种机制来实现线程之间的同步。例如,如果线程A将一个值写入到共享内存中,并且我们希望线程B对这个值进行一些操作,那么只有当线程A的写入操作完成之后,线程B才能执行它的操作。如果没有同步,那么将会发生竞态条件(Race Condition),在这种情况下,代码执行结果的正确性将取决于硬件的不确定性。这种同步方法就是:

__syncthreads()

这个函数调用将确保线程块中的每个线程都执行完__syncthreads()前面的语句后,才会执行下一条语句。

下面,我们通过一个内积运算来加深理解。

Code:

#include "cuda_runtime.h"
#include<stdlib.h>
#include<stdio.h>

#define imin(a,b) (a<b?a:b)
#define sum_square(x) (x*(x+1)*(2*x+1)/6)

const int N = 33*1024;

const int threadsPerBlock = 256;
const int blocksPerGrid =
	            imin(32, (N+threadsPerBlock-1)/threadsPerBlock);

__global__ void dot_Jere(float *a, float *b, float *c)
{
	__shared__ float cache[threadsPerBlock];
	int tid = threadIdx.x + blockIdx.x * blockDim.x;
	int cacheIndex = threadIdx.x;

	float temp = 0;
	while (tid < N)
	{
		temp += a[tid] * b[tid];
		tid  += blockDim.x * gridDim.x;
	}

	// 设置cache中相应位置上的值
	cache[cacheIndex] = temp;

	// 对线程块中的线程进行同步
	__syncthreads();

	// 对于归约运算来说,以下代码要求threadPerBlock必须是2的指数
	int i = blockDim.x / 2;
	while (i != 0)
	{
		if (cacheIndex < i)
		{
			cache[cacheIndex] += cache[cacheIndex + i];
		}
		__syncthreads();
		i /= 2;
	}
	if (cacheIndex == 0)
	{
		c[blockIdx.x] = cache[0];
	}
}

int main()
{
	float *a, *b, c, *partial_c;
	float *dev_a, *dev_b, *dev_partial_c;

	a = (float*)malloc(N*sizeof(float));
	b = (float*)malloc(N*sizeof(float));
	partial_c = (float*)malloc(blocksPerGrid*sizeof(float));

	cudaMalloc((void**)&dev_a, N*sizeof(float));
	cudaMalloc((void**)&dev_b, N*sizeof(float));
	cudaMalloc((void**)&dev_partial_c, blocksPerGrid*sizeof(float));

	for (int i = 0; i < N; i++)
	{
		a[i] = i;
		b[i] = 2*i;
	}

	cudaMemcpy(dev_a, a, N*sizeof(float), cudaMemcpyHostToDevice);
	cudaMemcpy(dev_b, b, N*sizeof(float), cudaMemcpyHostToDevice);

	dot_Jere<<<blocksPerGrid, threadsPerBlock>>>(dev_a, dev_b, dev_partial_c);

	cudaMemcpy(partial_c, dev_partial_c, blocksPerGrid*sizeof(float), cudaMemcpyDeviceToHost);

	c = 0;
	for (int i = 0; i < blocksPerGrid; i++)
	{
		c += partial_c[i];
	}

	printf("Does GPU value %.6g = %.6g?\n", c, 2*sum_square((float)(N-1)));

	cudaFree(dev_a);
	cudaFree(dev_b);
	cudaFree(dev_partial_c);

	free(a);
	free(b);
	free(partial_c);

	return 0;
}

结果

首先,我们来关注一下核函数dot_Jere()。在核函数中,我们通过下面的语句:

       __shared__ float cache[threadsPerBlock];

定义一个共享内存 cache[ ],这个共享内存用来保存每个线程计算的乘积值。因为对于共享变量,编译器都将为每个线程块生成共享变量的一个副本,因此我们只需根据线程块中线程的数量来分配内存,即将它的大小设置为threadsPerBlock,这样就可以使线程块中的每个线程都能将它计算的临时结果保存在某个位置上。

在分配了共享内存后,开始计算数据索引:

       int tid = threadIdx.x + blockIdx.x * blockDim.x;
       int cacheIndex = threadIdx.x;

这个tid每个线程都不一样,在GPU中线程并行处理,tid表示着相应线程的ID。由上篇博文可知,blockIdx.x表示的是当前线程所在线程块在grid的x方向的索引,而blockDim.x表示线程块的大小。在上面的这个例子中,blockDim.x=256,blockIdx.x和threadIdx.x是变动的。

然后,在while循环中对tid有一个递增:

	float temp = 0;
	while (tid < N)
	{
		temp += a[tid] * b[tid];
		tid  += blockDim.x * gridDim.x;
	}

一开始,我其实对这个tid的递增值有点不太了解,在这个例子中gridDim.x=32,即tid每次递增值为256*32=8192,后来才知道,其实这个递增值和多CPU的并行程序的递增值是一个道理,在多CPU中递增值是CPU的个数。而在这里,这个递增值表示的是当前全部运行的线程数。因为内积的向量长度是33*1024=33792,大于当前运行的线程数,为了能够计算全部的内积,我们就引入while循环,多次运行,直到计算完全部向量对应位置的乘积。

当算法执行到现在后,我们需要对cache内的临时乘积值进行求和,但是这是一种危险的操作,因为我们需要确定所有对共享数组cache[ ]的写入操作在读取cache[]之前完成了。而这正是

	// 对线程块中的线程进行同步
	__syncthreads();

完成的功能。这个函数调用将确保线程块中的每个线程都执行完__syncthreads()前面的语句后,才会执行下一条语句。因此,在__syncthreads()函数下面的归约运算是在所有线程块内的线程都执行完cache写入操作后进行的。

归约运算如下:

	// 对于归约运算来说,以下代码要求threadPerBlock必须是2的指数
	int i = blockDim.x / 2;
	while (i != 0)
	{
		if (cacheIndex < i)
		{
			cache[cacheIndex] += cache[cacheIndex + i];
		}
		__syncthreads();
		i /= 2;
	}
	if (cacheIndex == 0)
	{
		c[blockIdx.x] = cache[0];
	}

这个归约运算的逻辑比较简单,就是每个线程将cache[]中的两个值相加起来,然后将结果保存回cache[]。由于每个线程都将两个值合并为一个值,那么在完成这个步骤后,得到的结果就是计算开始时数值数量的一半。在下一个步骤中,我们对这一半数值执行相同的操作。

当然,这里面也涉及到了同步问题。在对cache求和的迭代中,下一轮计算的启动必须确保上一轮cache的计算已经完结。因此,我们需要在

if (cacheIndex < i)

{

cache[cacheIndex] += cache[cacheIndex + i];

}

__syncthreads();

i /= 2;

中加入__syncthreads()。

现在,我们来考虑,如果将__syncthreads()放入if{  }内会有什么结果?在上面的代码中,我们只有当cacheIndex小于 i 时才需要更新共享内存cache[ ]。由于cacheIndex实际上就等于threadIdx.x,因而这意味着只有一部分的线程会更新共享内存。那么如果将__syncthreadx()放入if{  }内,即意味着只等待那些需要写入共享内存的线程,那是不是就能获得性能提升?

No,这只会让GPU停止响应。

Why!我们知道,线程块中的每个线程依次通过代码,每次一行。每个线程执行相同的指令,但对不同的数据进行计算。然而,当每个线程执行的指令放在一个条件语句中,这将意味着并不是每个线程都会执行这个指令,这种情况称为线程发散(Thread Divergence),在正常的环境下,发散的分支只会使得某些线程处于空闲状态,而其他线程将执行分支中的代码。但在__syncthread()情况中,线程发散的后果有点糟糕。CUDA架构将确保,除非线程块中的每个线程都执行了__syncthread(),否则没有任何线程能执行__syncthread()之后的指令。而当__syncthread()位于发散分支中,那么一些线程将永远都无法执行__syncthread()。因此,由于要确保在每个线程执行完__syncthread()后才能执行后面的语句,所以硬件将使这些线程保持等待。

最后,main()函数这一块的cuda语法上一篇博文已经讲了,它的逻辑也比较简单,我就不再多说了。

本文地址:http://blog.csdn.net/linj_m/article/details/41418425

更多资源请 关注博客:LinJM-机器视觉 微博:林建民-机器视觉

时间: 2024-08-01 05:18:28

CUDA学习日志:线程协作与例程的相关文章

CUDA学习日志:常量内存和纹理内存

接触CUDA的时间并不长,最开始是在cuda-convnet的代码中接触CUDA代码,当时确实看的比较痛苦.最近得空,在图书馆借了本<GPU高性能编程 CUDA实战>来看看,同时也整理一些博客来加强学习效果. Jeremy Lin 在上一篇博文中,我们谈到了如何利用共享内存来实现线程协作的问题.本篇博文我们主要来谈谈如何利用常量内存和纹理内存来提高程序性能. 常量内存 所谓的常量内存,从它的名字我们就可以知道,它是用来保存在核函数执行期间不会发生变化的数据.NVIDIA硬件提供了64KB的常量

CUDA学习日志:一个例子和编程接口

Jeremy Lin 上一篇最后有一个"Hello World"的例子,可是和C程序根本没差.现在我们来真正接触CUDA的代码到底要怎么写. 首先,Show the Code: #include "cuda_runtime.h" #include <stdio.h> const int N = 10; __global__ void add_Jeremy(int*a, int*b, int*c) { int tid = blockIdx.x; if (t

CUDA学习日志:windows开发环境配置

接触CUDA的时间并不长,最开始是在cuda-convnet的代码中接触CUDA代码,当时确实看的比较痛苦.最近得空,在图书馆借了本<GPU高性能编程 CUDA实战>来看看. Jeremy Lin 什么是CUDA CUDA(Compute Unified Device Architecture)是一种专门为提高并行程序开发效率而设计的计算架构.在构建高性能应用程序时,CUDA架构能充分发挥GPU的强大计算能力.更多的介绍,可以参考NVIDIA的ABOUT PAGE. CUDA开发环境配置 在开

疯狂Java学习笔记(65)---------线程协作

线程间协作的两种方式:wait.notify.notifyAll和Condition 在前面我们将了很多关于同步的问题,然而在现实中,需要线程之间的协作.比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权.因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去.因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,

winform学习日志(二十三)---------------socket(TCP)发送文件

一:由于在上一个随笔的基础之上拓展的所以直接上代码,客户端: using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; using System.Net.Sockets; using Sys

CUDA学习之二:shared_memory使用,矩阵相乘

CUDA中使用shared_memory可以加速运算,在矩阵乘法中是一个体现. 矩阵C = A * B,正常运算时我们运用 C[i,j] = A[i,:] * B[:,j] 可以计算出结果.但是在CPU上完成这个运算我们需要大量的时间,设A[m,n],B[n,k],那么C矩阵为m*k,总体,我们需要做m*n*k次乘法运算,m*(b-1)*k次加法运算,并且是串行执行,总体的复杂度为O(m*n*k) . 矩阵类: 1 class Matrix 2 { 3 public: 4 int cols; /

cocos2d-x学习日志(18) --程序是如何开始运行与结束?

问题的由来 怎么样使用 Cocos2d-x 快速开发游戏,方法很简单,你可以看看其自带的例程,或者从网上搜索教程,运行起第一个HelloWorld,然后在 HelloWorld 里面写相关逻辑代码,添加我们的层.精灵等 ~ 我们并不一定需要知道 Cocos2d-x 是如何运行或者在各种平台之上运行,也不用知道 Cocos2d-x 的游戏是如何运行起来的,它又是如何渲染界面的 ~~~ 两个入口 程序入口的概念是相对的,AppDelegate 作为跨平台程序入口,在这之上做了另一层的封装,封装了不同

CUDA学习之一:二维矩阵加法

今天忙活了3个小时,竟然被一个苦恼的CUDA小例程给困住了,本来是参照Rachal zhang大神的CUDA学习笔记来一个模仿,结果却自己给自己糊里糊涂,最后还是弄明白了一些. RZ大神对CUDA关于kernel,memory的介绍还是蛮清楚,看完决定写一个二维数组的加法.如果是C++里的加法,那就简单了,用C[i][j] = A[i][j] +B[i][j]就可以. 1 void CppMatAdd(int A[M][N],int B[M][N],int C[M][N]){ 2 for(int

CUDA学习5 常量内存与事件

当线程束中的所有线程都访问相同的只读数据时,使用常量内存将获得额外的性能提升. 常量内存大小限制为64k. 以下摘自hackairM的博文CUDA学习--内存处理之常量内存(4). 常量内存其实只是全局内存的一种虚拟地址形式,并没有特殊保留的常量内存块.常量内存有两个特性,一个是高速缓存,另一个是它支持将单个值广播到线程束中的每个线程.但要注意的是,对于那些数据不太集中或者数据重用率不高的内存访问,尽量不要使用常量内存. 当常量内存将数据分配或广播到线程束中的每个线程时(注意,实际上硬件会将单次