[CUDA] CUDA编程入门

CUDA编程入门

Kernels

kernel在cuda中指的是一个函数,当一个kernel被调用的时候,gpu会同时启动很多个线程来执行这一个kernel,这样就实现了并行化;每个线程执行这一kernel将通过线程号来对应输入数据的下标,这样保证每个thread执行的kernel一样,但是处理的数据不一样。

一个kernel在cuda中可以这么定义:

// kernel定义
__global__ void VecAdd(float* A,float* B, float C){
    int i = threadIdx.x;
    C[i] = A[i] + B[i]
}

// 调用
VecAddd<<<1,N>>>(A,B,C);

上面代码中调用的时候通过指定<<<1,N>>>前者1表明使用1个block,每个block有N个threads,这样在函数调用内部通过取内置变量threadIdx来得到thread的索引,以此对应数组下标,实现并行化。(由于疫情在家,没有显卡run,显卡在学校放着。。。)。

__global__前缀表示在GPU上执行,其他对应的前缀有如下特点:

  1. __global__:在GPU上执行,可以在CPU上被调用,也可以在GPU上被调用
  2. __device__:在GPU上执行,只能在GPU上被调用
  3. __host__:在CPU上执行,只能在CPU上被调用

实际上,可以先缕一缕GPU中内存的一些关系:

可以看到,GPU的整个内存被划分为了多个grid,每个grid有很多block,而每个block都共享同样的内存,每个block的多条thread针对共享的内存并行执行不同的操作。

上面的例子中只是针对thredIdx是一维的情况,实际上,为了方便,我们可以直接将threadIdx变成多维的,以方便我们对矩阵或者更高维的数据的处理,这跟C语言里的二维数组等概念是一样的,就是用x和y分别代表一个维度的下标。

__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]){
    int x = threadIdx.x;
    int y = threadIdx.y;
    C[x][y] = A[x][y] + B[x][y];
}

int main(){
    int num_blocks = 1;
    dim3 threads_per_block(N,N);
    MatAdd<<<num_blocks,threads_per_block>>>(A,B,C);
}

这个例子是相对很容易理解的了。需要知道的是,每个block的threads数目是有最大限制的,不能超过一定的值,书上说现在的GPU每个block最多是1024个thread。所以上面的例子其实并不能处理很大的矩阵,那么要处理很大的矩阵的时候该怎么办呢?

可以用上block:

___global___ void MatAdd(float A[N][N], float B[N][N], float C[N][N]){
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    int j = blockIdx.y * blockDim.y + threadIdx.y;
    C[i][j] = A[i][j] + B[i][j];
}
int main(){
    dim3 threads_per_block(16,16);
    dim3 num_blocks(N/num_blocks.x,N/num_blocks.y);
    MatAdd(A,B,C);
}

这个例子充分利用了block数量,这样每个block是16*16也就是256个threads,假设N = 32,那么num_blocks = (2,2)也就是用了4个block。上面blockIdx.x取值是[0,1],blockDim.x取值是16,threadIdx.x取值是[0,15].

假设每个block最多可以用1024个thread,那么其实可以处理$N_{max} = 32 * \sqrt{num_blocks} $阶矩阵。

那这里就有个疑问了,不是说一个block一块内存吗,不同block之间不share内存,那这个代码感觉所有block都share同一块内存的样子呀?其实这里跟share不share没任何关系。。每个thread都在独立计算一条语句,大家根本没有说有那种你先我后的协作关系,所以谈不上share,所以大家都是independent的,从global memory里读入,然后写到global memory。

Compute Capability

一般用x.y来表示一款GPU的计算能力,x相同的表示架构相同,y只是在这个架构上做了一些increasement。

现在GPU与计算能力的x之间有下图这样的对应:

7 Volta
6 Pascal
5 Maxwell
3 Kepler
2 Fermi
1 Tesla

这里注意不要与cuda版本号搞混了,计算能力描述的是gpu,而非软件版本。

Memory Allocation

这节涉及之前省略的,之前的代码没有考虑A、B、C的内存申请,这一节讨论的就是如何把host的内存(cpu data)转到device里(gpu data)。

如果要申请一块连续的GPU内存空间,直接可以通过调用cudaMalloc()来实现,调用cudaFree()释放空间,那么把host上内存copy到gpu上是通过调用cudaMemcpy()来实现。

__global__ void VecAdd(float* A, float* B, float* C){
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    // 根据下面的分析,i是可能大于等于N的,当N>threadsPerBlock且为threadsPerBlock的非整数倍的时候,这时候会引入多一个block来运算,其中多余的threads不应该参与运算,所以下面if需要判断。
    if (i < N){
        C[i] = A[i] + B[i];
    }

}

int main(){
    int N = 32;
    size_t size = N * sizeof(float);
    float* h_A = (float*)malloc(size);
    float* h_B = (float*)malloc(size);
    float* h_C = (float*)malloc(size);

    float* d_A;
    float* d_B;
    float* d_C;
    
    cudaMalloc(&d_A,size);
    cudaMalloc(&d_B,size);
    cudaMalloc(&d_C,size);
    
    cudaMemcpy(d_A, h_A,size,cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B,size,cudaMemcpyHostToDevice);

    int threadsPerBlock = 256;
    // 由于threadsPerBlock通常是定的,所以我们需要调整的一般是Blocks的数量
    // 考虑到如果N < threadsPerBlock,那么N/threadsPerBlock就是0,但这是是少需要一个block
    // 如果N = threadsPerBlock,这时候如果(N + threadsPerBlock)/threadsPerBlock就是2,但其实这时候只需要一个block
    // 综上,应该是下式才满足条件
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

    VecAdd<<<blocksPerGrid,threadsPerBlock>>>(d_A,d_B,d_C,N);
    
    cudaMemcpy(h_C,d_C,cudaMemcpyDeviceToHost);
    
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);
}

cudaMalloc ( void** devPtr, size_t size )

Parameters
  • devPtr
    • Pointer to allocated device memory
  • size
    • Requested allocation size in bytes

第一个参数是地址的指针,显然cudaMalloc只能分配线性内存,线性内存访问起来是相当块的。在传统C语言里,我们如果要取用malloc申请二维数组,可能先申请一些一维数组用来存储首地址指针,每个首地址再去指向一个申请的一维数组,实现二维数组,在CUDA里可以通过cudaMallocPitch来申请二维数组,得到的地址仍然是线性的,因为访问块。

cudaMallocPitch ( void** devPtr, size_t* pitch, size_t width, size_t height )

Parameters
  • devPtr

    • Pointer to allocated pitched device memory
  • pitch

    • Pitch for allocation
  • width

    • Requested pitched allocation width (in bytes)
  • height

    • Requested pitched allocation height

pitch参数表示的是“宽度”,可以理解为一行有多少个元素乘以sizeof(float),是返回值。

书上的一个例子为:

__global__ Mykernel(float* devPtr,size_t pitch, int width, int height){
    for(int r = 0;r < height; ++r){
        float* row = (float*)((char*)devPtr + r * pitch); // 转char* 是因为原来的devPtr是float*的,加法运算会考虑stride。
        for(int c=0;c<width;++c){
            float val = row[c];
        }
    }
}

int width = 64,height = 64;
float* devPtr;
size_t pitch; // sizeof(float) * width
cudaMallocPitch(&devPtr,&pitch,width * sizeof(float),height); // height不用乘sizeof(float)是因为第一维的指针是void*

Mykernel<<<100,512>>>(devPtr,pitch,width,height);

Shared Memory

本节通过一个矩阵相乘的例子,来说明通过block内shared memory来减少从global memory读取和写入数据的次数,从而加速矩阵相乘的速度。

首先是没有shared memory的代码实现:

typedef struct{
    int width;
    int height;
    float* elements;
} Matrix;

#define BLOCK_SIZE 16

__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);

void MatMul(const Matrix A, const Matrix B, Matrix C){
    Matrix d_A;
    d_A.width = A.width;
    d_A.height = A.height;
    size_t size = d_A.width * d_A.height * sizeof(float);
    cudaMalloc(&d_A.elements,size);
    cudaMemcpy(d_A.elements,A.elements,size,cudaMemcpyHostToDevice);

    Matrix d_B;
    d_B.width = B.width;
    d_B.height = B.height;
    size_t size = d_B.width * d_B.height * sizeof(float);
    cudaMalloc(&d_B.elements,size);
    cudaMemcpy(d_B.elements,B.elements,size,cudaMemcpyHostToDevice);

    Matrix d_C;
    d_C.width = C.width;
    d_C.height = C.height;
    size_t size = d_C.width * d_C.height * sizeof(float);
    cudaMalloc(&d_C.elements,size);
    
    dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
    // 列索引和行索引划分
    dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);
    MatMulKernel<<<dimBlock,dimGrid>>>(d_A,d_B,d_C);

    cudaMemcpy(C.elements,d_C.elements,size,cudaMemcpyDeviceToHost);
    cudaFree(d_A.elements);
    cudaFree(d_B.elements);
    cudaFree(d_C.elements);
}

__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C){
    float Cvalue = 0;
    // 这里求对应A矩阵的第row行以及B矩阵的第j列
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    for(int i=0;i<A.width;i++){
        Cvalue += A.elements[row * A.width + i] * B.elements[i * B.width + col];
    }
    C.elements[row * C.width + col] = Cvalue;
}

代码很好理解,每个thread负责通过一个循环来计算输出矩阵C中的一个数值,需要注意的是,C矩阵中的每个数值计算,都需要一个thread从global memory中读取数据,计算完成,然后写回。下图表达的就是这样的一个计算实例:

所以总的读取和写入次数是非常频繁的,为了减少访问global memory的次数,可以对C矩阵分块求取,C矩阵中的每个分块矩阵由一个block负责计算,这block一次将数据读入block内的共享内存,然后thread在共享内存上计算,最后写入到global memory,这大大减少了访问global memory的次数,加速了计算。

// 加的这个stride就是为了表示原矩阵width,这里的width和height可以是子矩阵的width和height
// elements是子矩阵的首地址
// M(row,col) = *(M.elements + row * M.stride + col)
typedef struct{
    int width;
    int height;
    int stride;
    float* elements;
} Matrix;
#define BLOCK_SIZE 16
__device__ void SetElement(Matrix A, int row, int col, float value){
    // 虽然A是一个局部变量,但是A里面的elements指向的东西没被深拷贝
    A.elements[row * A.stride + col] = value; // 乘以A的stride是因为A跨行的stride和原矩阵一致
}
__device__ float GetElement(Matrix A,int row, int col){
    return A.elements[row * A.stride + col];
}

__device__ Matrix getSubMatrix(Matrix A,int row, int col){
    Matrix Asub;
    Asub.width = BLOCK_SIZE;
    Asub.height = BLOCK_SIZE;
    Asub.stride = A.stride;
    Asub.elements = A.elements + (BLOCK_SIZE * row) * stride + (BLOCK_SIZE*col) // 相当于乘以BLOCK_SIZE
    return Asub;
}


__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);

void MatMul(const Matrix A, const Matrix B, Matrix C){
    Matrix d_A;
    d_A.width = A.width;
    d_A.height = A.height;
    size_t size = d_A.width * d_A.height * sizeof(float);
    cudaMalloc(&d_A.elements,size);
    cudaMemcpy(d_A.elements,A.elements,size,cudaMemcpyHostToDevice);

    Matrix d_B;
    d_B.width = B.width;
    d_B.height = B.height;
    size_t size = d_B.width * d_B.height * sizeof(float);
    cudaMalloc(&d_B.elements,size);
    cudaMemcpy(d_B.elements,B.elements,size,cudaMemcpyHostToDevice);

    Matrix d_C;
    d_C.width = C.width;
    d_C.height = C.height;
    size_t size = d_C.width * d_C.height * sizeof(float);
    cudaMalloc(&d_C.elements,size);
    
    dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
    // 列索引和行索引划分
    dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);
    MatMulKernel<<<dimBlock,dimGrid>>>(d_A,d_B,d_C);

    cudaMemcpy(C.elements,d_C.elements,size,cudaMemcpyDeviceToHost);
    cudaFree(d_A.elements);
    cudaFree(d_B.elements);
    cudaFree(d_C.elements);
}

__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C){
    int block_row = blockIdx.x;
    int block_rol = blockIdx.y;
    Matrix Csub = GetSubMatrix(C,block_row,block_col);
    float Cvalue = 0;
    int row = threadIdx.x;
    int col = threadIdx.y;

    for(int i=0;i<(A.width / BLOCK_SIZE);++i){
        // 这里不是直接一行算,A矩阵和B矩阵也是按block来,最后是Cvalue求和不影响结果
        Matrix Asub = GetSubMatrix(A,block_row,i);
        Matrix Bsub = GetSubMatrix(B,i,block_rol);
        // 直接生命的对象 局部生命周期

        __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
        
        As[row][col] = GetElement(Asub,row,col);
        Bs[row][col] = GetElement(Bsub,row,col);
        // 保证整个子矩阵都被读入
        // 也就是一个block的所有thread都执行到这里
        __syncthreads();

        for(int e=0; e<BLOCK_SIZE;++e){
            Cvalue += As[row][e] * Bs[e][col];
        }
        // 保证这个block算完,然后下一次训练读入下一轮的sub matrix
        __syncthreads();
    }
    SetElement(Csub,row,col,Cvalue)
}

假设A矩阵是m行n列的,B矩阵是p行q列的,如果用前一种方法,A和B矩阵从global memory中被读取的次数是np次,后一种方法是(n/block_size) * (p/block_size)次。(书上那种说法应该指的是同一行、列数据被读取的次数,跟我这里不同)。

大致可用下图表示:

Pinned Memory

Host端的内存分为页内存(Pageable Memory)和锁存(Pinned Memory),前者在内存不足时会与虚拟内存交换,把数据放在虚拟内存中,后者内存是固定的。

而内存拷贝是一个非常耗时的操作,由于GPU知道内存的物理地址,因此就可以使用DMA技术来在GPU和CPU之间复制数据.当使用可分页的内存进行复制时(使用malloc),CUDA驱动程序仍会通过dram把数据传给GPU,这时复制操作会执行两遍,第一遍从可分页内存复制一块到临时的页锁定内存,第二遍是再从这个页锁定内存复制到GPU上.当从可分页内存中执行复制时,复制速度将受限制于PCIE总线的传输速度和系统前段速度相对较低的一方.在某些系统中,这些总线在带宽上有着巨大的差异,因此当在GPU和主机之间复制数据时,这种差异会使页锁定主机内存比标准可分页的性能要高大约2倍.即使PCIE的速度于前端总线的速度相等,由于可分页内训需要更多一次的CPU参与复制操作,也会带来额外的开销.

所以在内存足够的时候,可以使用pinned memory来加速数据读取。

猜你喜欢

转载自www.cnblogs.com/aoru45/p/12650861.html