前面我们看到将一个标准C函数放到GPU设备上运行是很容易的。只需要在函数定义前面加上 __globle__ 修饰符,并通过一种特殊的尖括号语法来调用它,就可以在GPU上执行这个函数。然而,前面的示例只调用了一个和函数,并且该函数在GPU上以串行方式运行。本章将学习如何启动一个并行执行的设备函数。
我们先通过一个简单的示例说明线程的概念,以及如何使用 CUDA C来实现线程。假设有两组数据,我们需要将这两组数据中对应的元素两两相加,并将结果保存在第三数组中。
4.1.1 基于CPU的矢量求和
首先看看如何通过传统的C代码来实现这个求和运算:
#include "../common/book.h"
#define N 10
void add( int *a, int *b, int *c ) {
int tid = 0; // 这是第0个CPU,因此索引从0开始
while (tid < N) {
c[tid] = a[tid] + b[tid];
tid += 1; // 由于只有一个CPU,所以每次递增1
}
}
int main( void ) {
int a[N], b[N], c[N];
// 在CPU上为数组a,b赋初值
for (int i=0; i
我们对add()函数做简要分析,看看为什么这个函数有些过于复杂:
函数在while循环中计算总和,其中索引 tid 的取值范围为0到N-1。我们将a[ ] 和 b[ ] 的对应元素相加起来,并将结果保存在c[ ] 的相应元素中。通常,可以用更简单的方式来编写这段代码,例如:
void add(int *a, *b, *c){
for(i=0; i
然而,上面采用while循环的方式虽然有些复杂,但这是为了使代码能够在拥有多个CPU或者CP核的系统上并行运行。例如,在双核处理器上可以将每次递增的大小改为2,这样其中一个核从 tid=0 开始执行循环 ,而另一个核从 tid=1开始执行循环。第一个核将偶数索引的元素相加,而第二个核将奇数索引的元素相加。着相当于在每个CPU核上执行以下代码:
// 第1个CPU内核
void add(int *a, int *b, int *c){
int tid = 0; // 第1个核从tid=0开始循环
while (tid < N) {
c[tid] = a[tid] + b[tid];
tid += 2; // 有两个核, 所以每次累加2
}
}
// 第2个CPU内核
void add(int *a, int *b, int *c){
int tid = 1; // 第2个核从tid=1开始循环
while (tid < N) {
c[tid] = a[tid] + b[tid];
tid += 2; // 有两个核, 所以每次累加2
}
}
当然,要在CPU上世纪执行这个运算,还需要增加更多的工作,例如编写一定数量的代码来创建工作线程,每个线程都执行函数add(),并假设每个线程都将并行执行。但是,这种假设是理想而又不实际的,线程调度机制的实际运行情况往往并非如此。
4.1.2 基于GPU的矢量求和
我们可以在GPU上实现相同的加法运算,这需要将 add() 编写为一个设备函数。下面首先给出main()函数:
/* add_loop_gpu.cu */
#include "../common/book.h"
#define N 10
int main( void ) {
int a[N], b[N], c[N];
int *dev_a, *dev_b, *dev_c;
// 在GPU上分配内存
HANDLE_ERROR( cudaMalloc( (void**)&dev_a, N * sizeof(int) ) );
HANDLE_ERROR( cudaMalloc( (void**)&dev_b, N * sizeof(int) ) );
HANDLE_ERROR( cudaMalloc( (void**)&dev_c, N * sizeof(int) ) );
// 在CPU上为数组a, b赋初值
for (int i=0; i>>( dev_a, dev_b, dev_c );
// 将数组c从GPU复制到CPU
HANDLE_ERROR( cudaMemcpy( c, dev_c, N * sizeof(int),
cudaMemcpyDeviceToHost ) );
// display the results
for (int i=0; i
上述代码使用了一些通用模式:
上面代码中在CPU中对数组 a, b 赋初值只是为了说明如何在GPU上实现两个矢量的加法运算,事实上,如果在GPU上为数组赋值,这个步骤会执行得更快。
接下来是add()函数,这个函数看上去非常类似于基于CPU实现的add() :
__global__ void add( int *a, int *b, int *c ) {
int tid = blockIdx.x; // 计算该索引处的数据
if (tid < N)
c[tid] = a[tid] + b[tid];
}
上面add()函数使用了一种通用模式:
前面提到了尖括号中的两个参数将传递给运行时,作用是告诉运行时如何启动核函数,明确对运行时参数进行解释见CUDA和函数运行参数。
接下来的示例将介绍如何绘制Julia集的曲线,对于不熟悉Julia集的读者,可以简单地将Julia集认为是满足某个复数计算函数的所有点构成的边界。
生成Julia集的算法非常简单。Julia集的基本算法是,通过一个简单的迭代等式对复平面中的点求值。如果在计算某个点时,迭代等式的计算结果是发散的,那么这个点就不属于Julia集合。相反,如果在迭代等式中计算得到的一系列值都位于某个边界范围之内,那么这个点就属于Julia集合。迭代等式如下:
迭代过程包括:首先计算当前值的平方,然后再加一个常数以得到下一个值。
基于CPU的Julia集
/* julia_cpu.cu */
#include "../common/book.h"
#include "../common/cpu_bitmap.h"
#define DIM 1000
struct cuComplex {
float r;
float i;
cuComplex( float a, float b ) : r(a), i(b) {}
float magnitude2( void ) { return r * r + i * i; }
cuComplex operator*(const cuComplex& a) {
return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
}
cuComplex operator+(const cuComplex& a) {
return cuComplex(r+a.r, i+a.i);
}
};
int julia( int x, int y ) {
const float scale = 1.5;
float jx = scale * (float)(DIM/2 - x)/(DIM/2);
float jy = scale * (float)(DIM/2 - y)/(DIM/2);
cuComplex c(-0.8, 0.156);
cuComplex a(jx, jy);
int i = 0;
for (i=0; i<200; i++) {
a = a * a + c;
if (a.magnitude2() > 1000)
return 0;
}
return 1;
}
void kernel( unsigned char *ptr ){
for (int y=0; y
基于GPU的Julia集
/* julia_gpu.cu */
#include "../common/book.h"
#include "../common/cpu_bitmap.h"
#define DIM 1000
struct cuComplex {
float r;
float i;
cuComplex( float a, float b ) : r(a), i(b) {}
__device__ float magnitude2( void ) {
return r * r + i * i;
}
__device__ cuComplex operator*(const cuComplex& a) {
return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
}
__device__ cuComplex operator+(const cuComplex& a) {
return cuComplex(r+a.r, i+a.i);
}
};
__device__ int julia( int x, int y ) {
const float scale = 1.5;
float jx = scale * (float)(DIM/2 - x)/(DIM/2);
float jy = scale * (float)(DIM/2 - y)/(DIM/2);
cuComplex c(-0.8, 0.156);
cuComplex a(jx, jy);
int i = 0;
for (i=0; i<200; i++) {
a = a * a + c;
if (a.magnitude2() > 1000)
return 0;
}
return 1;
}
__global__ void kernel( unsigned char *ptr ) {
// map from blockIdx to pixel position
int x = blockIdx.x;
int y = blockIdx.y;
int offset = x + y * gridDim.x;
// now calculate the value at that position
int juliaValue = julia( x, y );
ptr[offset*4 + 0] = 255 * juliaValue;
ptr[offset*4 + 1] = 0;
ptr[offset*4 + 2] = 0;
ptr[offset*4 + 3] = 255;
}
// globals needed by the update routine
struct DataBlock {
unsigned char *dev_bitmap;
};
int main( void ) {
DataBlock data;
CPUBitmap bitmap( DIM, DIM, &data );
unsigned char *dev_bitmap;
HANDLE_ERROR( cudaMalloc( (void**)&dev_bitmap, bitmap.image_size() ) );
data.dev_bitmap = dev_bitmap;
dim3 grid(DIM,DIM);
kernel<<>>( dev_bitmap );
HANDLE_ERROR( cudaMemcpy( bitmap.get_ptr(), dev_bitmap,
bitmap.image_size(),
cudaMemcpyDeviceToHost ) );
HANDLE_ERROR( cudaFree( dev_bitmap ) );
bitmap.display_and_exit();
}
到目前为止,我们已经看到了如何告诉CUDA运行时在线程块上并行执行程序。我们把在GPU上启动的线程块集合称为一个线程格。从名字的含义可以看出,线程格既可以是一位的线程块集合,也可以是二维的线程块集合。核函数的每个副本都可以通过内置变量blockIdx来判断那个线程块正在执行它。同样,它还可以通过内置变量gridDim来获得线程格的大小。这两个内置变量在核函数中都是非常有用的,可以用来计算每个线程块需要的数据索引。