CUDA 学习笔记 (二) 【Chapter4 CUDA并行编程】

Capter4  CUDA并行编程

前面我们看到将一个标准C函数放到GPU设备上运行是很容易的。只需要在函数定义前面加上 __globle__ 修饰符,并通过一种特殊的尖括号语法来调用它,就可以在GPU上执行这个函数。然而,前面的示例只调用了一个和函数,并且该函数在GPU上以串行方式运行。本章将学习如何启动一个并行执行的设备函数。


4.1 矢量求和运算

我们先通过一个简单的示例说明线程的概念,以及如何使用 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<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    add( a, b, c );

    // display the results
    for (int i=0; i<N; i++) {
        printf( "%d + %d = %d\n", a[i], b[i], c[i] );
    }

    return 0;
}

我们对add()函数做简要分析,看看为什么这个函数有些过于复杂:

函数在while循环中计算总和,其中索引 tid 的取值范围为0到N-1。我们将a[ ] 和 b[ ] 的对应元素相加起来,并将结果保存在c[ ] 的相应元素中。通常,可以用更简单的方式来编写这段代码,例如:

void add(int *a, *b, *c){
    for(i=0; i<N; i++)
        c[i]=a[i]+b[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<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    // 将数组a,b从CPU复制到GPU
    HANDLE_ERROR( cudaMemcpy( dev_a, a, N * sizeof(int),
                              cudaMemcpyHostToDevice ) );
    HANDLE_ERROR( cudaMemcpy( dev_b, b, N * sizeof(int),
                              cudaMemcpyHostToDevice ) );

    add<<<N,1>>>( 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<N; i++) {
        printf( "%d + %d = %d\n", a[i], b[i], c[i] );
    }

    // 释放在GPU上分配的内存
    HANDLE_ERROR( cudaFree( dev_a ) );
    HANDLE_ERROR( cudaFree( dev_b ) );
    HANDLE_ERROR( cudaFree( dev_c ) );

    return 0;
}

上述代码使用了一些通用模式:

  • 调用cudaMalloc() 在设备上为三个数组分配内存: 在其中两个数组(dev_a和dev_b)中包含了输入值,而在数组dev_c中包含了计算结果;
  • 为了避免内存泄漏,在使用完GPU内存后通过cudaFree() 释放他们;
  • 通过cudaMemcpy()将输入数据复制到设备中,同时指定参数 cudaMemcpyHostToDevice,在计算完成后,将计算结果通过参数cudaMemcpyDeviceToHost是复制回主机。
  • 通过尖括号语法, 在主机main() 函数中执行add()设备代码。

上面代码中在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()函数使用了一种通用模式:

  • 编写一个在设备上执行的函数add。采用C来编写代码,并在函数名前添加修饰符__global__ .

前面提到了尖括号中的两个参数将传递给运行时,作用是告诉运行时如何启动核函数,明确对运行时参数进行解释见CUDA和函数运行参数


4.2  一个有趣的示例

接下来的示例将介绍如何绘制Julia集的曲线,对于不熟悉Julia集的读者,可以简单地将Julia集认为是满足某个复数计算函数的所有点构成的边界。

生成Julia集的算法非常简单。Julia集的基本算法是,通过一个简单的迭代等式对复平面中的点求值。如果在计算某个点时,迭代等式的计算结果是发散的,那么这个点就不属于Julia集合。相反,如果在迭代等式中计算得到的一系列值都位于某个边界范围之内,那么这个点就属于Julia集合。迭代等式如下:

                                                                       Z_{n+1} = Z_{n}^{2} + C

迭代过程包括:首先计算当前值的平方,然后再加一个常数以得到下一个值。

基于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<DIM; y++) {
        for (int x=0; x<DIM; x++) {
            int offset = x + y * DIM;

            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;
        }
    }
 }

int main( void ) {
    CPUBitmap bitmap( DIM, DIM );
    unsigned char *ptr = bitmap.get_ptr();

    kernel( ptr );

    bitmap.display_and_exit();
}

基于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<<<grid,1>>>( dev_bitmap );

    HANDLE_ERROR( cudaMemcpy( bitmap.get_ptr(), dev_bitmap,
                              bitmap.image_size(),
                              cudaMemcpyDeviceToHost ) );
                              
    HANDLE_ERROR( cudaFree( dev_bitmap ) );
                              
    bitmap.display_and_exit();
}

4.3 本章小结

到目前为止,我们已经看到了如何告诉CUDA运行时在线程块上并行执行程序。我们把在GPU上启动的线程块集合称为一个线程格。从名字的含义可以看出,线程格既可以是一位的线程块集合,也可以是二维的线程块集合。核函数的每个副本都可以通过内置变量blockIdx来判断那个线程块正在执行它。同样,它还可以通过内置变量gridDim来获得线程格的大小。这两个内置变量在核函数中都是非常有用的,可以用来计算每个线程块需要的数据索引。


猜你喜欢

转载自blog.csdn.net/fuxiaoxiaoyue/article/details/83755352