CUDA 编程(入门版)


简介

本文主要介绍一些 cuda 关于矩阵相关运算的入门编程及相关技巧,是我的学习笔记,比较适合初学者。

矩阵相加

这一节通过矩阵相加来介绍 cuda 编程的常规流程,并介绍一些术语

流程

  • memory alloc 用于在 gpu 上开辟空间
cudaMalloc((void**) &d_o, sizeof(float) * (M * N));
cudaMalloc((void**) &d_a, sizeof(float) * (M * N));
cudaMalloc((void**) &d_b, sizeof(float) * (M * N));

其中 M 和 N 分别是矩阵的行和列

  • copy data
cudaMemcpy(d_o, h_o, sizeof(float) * (M * N), cudaMemcpyHostToDevice);
cudaMemcpy(d_a, h_a, sizeof(float) * (M * N), cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, sizeof(float) * (M * N), cudaMemcpyHostToDevice);

把 cpu 上的数据拷贝到 gpu 上来,一般我们把 cpu 叫做 Host,然后 gpu 叫做 device,可以简单理解 gpu 时主机上受 cpu 支配的一个设备

  • kernel function
__global__ void m_add(float *c, float *a, float *b, int m, int n){
    // get ind
    const int block_ind = blockIdx.x;
    const int thread_ind = threadIdx.x;
    const int step = blockDim.x * gridDim.x;
    int curr_ind = block_ind * blockDim.x + thread_ind;

    while (curr_ind < m * n) {
        c[curr_ind] = a[curr_ind] + b[curr_ind];
        curr_ind += step;
    }
}

kernel function 必须要有关键字 __global__ 需要计算的是 step,就是并行计算一次,下标需要跨越多少来更换,它等于 每个 block 的有多少线程即 blockDim.x 和每个 grid 有多少即 gridDim.x 。这里的示范是只有一个维度的并行,矩阵可以 x, y 两个维度

  • copy results back to CPU
cudaMemcpy(h_o, d_o, sizeof(float) * (M * N), cudaMemcpyDeviceToHost);
cudaMemcpy(h_a, d_a, sizeof(float) * (M * N), cudaMemcpyDeviceToHost);
cudaMemcpy(h_b, d_b, sizeof(float) * (M * N), cudaMemcpyDeviceToHost);

将计算结果拷回 cpu 中

  • free memory
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_o);
free(h_a);
free(h_b);

所以一般一共 5 个过程:开辟空间拷贝数据执行 kernel 函数拷贝结果释放内存

最后 nvcc add.cu -o add.o 生成可执行文件

一些 API 的解释

  • cudaEvent_t

    可以理解为一个数据类型 – 指针类型的变量

  • cudaEventCreate(cudaEvent_t *event)

    创建 event 变量

  • cudaEventRecord(cudaEvent_t event, cudaStream_t stream __dv(0))

    记录 event 事件,对同一个 event,可以调用多次 cudaEventRecord(), 最新调用的值覆盖之前的旧值

  • cudaEventSynchronize(cudaEvent_t event)

    用于 event 事件的同步,在调用此函数之后保证 event 已经结束

  • cudaEventElapsedTime(float *ms, cudaEvent_t start, cudaEvent_t end)

    统计start和end 2个event之间的时间间隔, 单位毫秒(ms),此函数的时间分辨率为0.5us

二维的矩阵相加示例

#include <iostream>

#define M 2000
#define N 1000

using namespace std;

__global__ void m_add(float c[][N], float a[][N], float b[][N], int m, int n){
    // get ind
    int x_i = threadIdx.x + blockIdx.x;
    int y_i = threadIdx.y + blockIdx.y;

    if (x_i < m && y_i < n) {
        c[x_i][y_i] = a[x_i][y_i] + b[x_i][y_i];
    }
}

int main() {
    float (*h_a)[N] = new float[M][N];
    float (*h_b)[N] = new float[M][N];
    float (*h_o)[N] = new float[M][N];
    float (*d_a)[N], (*d_b)[N], (*d_o)[N];

    // define timer
    cudaEvent_t start, stop;

    // initialize data
    for (int i = 0; i < M; ++i) {
        for (int j = 0; j < N; ++j) {
            h_a[i][j] = i + j + 2.232f;
            h_b[i][j] = i + 2 * j;
        }
    }

    // GPU memory allco
    cudaMalloc((void**) &d_a, sizeof(float) * (M * N));
    cudaMalloc((void**) &d_b, sizeof(float) * (M * N));
    cudaMalloc((void**) &d_o, sizeof(float) * (M * N));

    // copy from CPU to GPU
    cudaMemcpy(d_a, h_a, sizeof(float) * (M * N), cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, sizeof(float) * (M * N), cudaMemcpyHostToDevice);

    cudaEventCreate(&start);
    cudaEventCreate(&stop);
    cudaEventRecord(start, 0);
    // cuda add
    dim3 block_num(M, N);
    m_add<<<block_num, 1>>>(d_o, d_a, d_b, M, N);

    // copy results back to CPU
    cudaMemcpy(h_o, d_o, sizeof(float) * (M * N), cudaMemcpyDeviceToHost);

    cudaEventRecord(stop, 0);
    cudaEventSynchronize(stop);
    float elapsedTime;
    cudaEventElapsedTime(&elapsedTime, start, stop);

    cout << "The kernel function is during: " << elapsedTime << endl;

    cout << "The final number (M * N): " << h_o[234][21];
    // free GPU mem
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_o);
    free(h_a);
    free(h_b);

    return 0;
}

向量内积

向量内积分为两步:点对点相乘,然后所有元素求和。这节内容的重点不是讲点对点相乘,因为其实这个和相加一样的。主要讲第二步求和,也叫做归约。

不同的向量内积计算方式

  • 分散归约

如上图,是分散归约的示意图,也就是每轮求和,都与自己旁边的线程进行相加,最后归约到 0 线程,代码如下:

__global__ void  vector_dot_product(float *c, float *a, float *b, int n){
    // alloc shared momery for effictive computing
    __shared__ float tmp_arr[thread_num];
    const int t_idx = threadIdx.x;
    const int b_dim = blockDim.x;
    int tid = t_idx;
    double temp = 0.0;
    
    while(tid<n) {
        temp += a[tid] * b[tid];
		tid += b_dim;
    }
    tmp_arr[t_idx] = temp;

    // wait all threads for temp
    __syncthreads();
    int i = 2, j = 1;
    while(i <= thread_num) {
        if ((t_idx % i) == 0) {
	    tmp_arr[t_idx] += tmp_arr[t_idx + j];
	}
	__syncthreads();
	i *= 2;
	j *= 2;
    }
    if (t_idx == 0) c[0] = tmp_arr[0];
}

分散归约存在缺陷:访问相邻两个单元,违背了访存对齐原则;需要多个 warp 性能损失;可能引发 bank conflict。__syncthreads(); 也就是等待同一个 block 的线程都完成此操作。

  • 低线程归约

如图,顾名思义就是将后面的线程全部归约到前面的线程中来,每次减少一半。代码如下:

__global__ void  vector_dot_product(float *c, float *a, float *b, int n){
    // alloc shared momery for effictive computing
    __shared__ float tmp_arr[thread_num];
    const int t_idx = threadIdx.x;
    const int b_dim = blockDim.x;
    int tid = t_idx;
    double temp = 0.0;

    while(tid<n) {
        temp += a[tid] * b[tid];
        tid += b_dim;
    }
    tmp_arr[t_idx] = temp;

    // wait all threads for temp
    __syncthreads();
    int i = thread_num / 2;
    while(i != 0) {
        if (t_idx < i) {
            tmp_arr[t_idx] += tmp_arr[t_idx + i];
        }
        __syncthreads();
       i /= 2;
    }
    if (t_idx == 0) c[0] = tmp_arr[0];
}
  • cpu 进行二次归约

以上都是单个 block 多线程的计算,显然如果开多个 block,以上只是计算了每个 block 的结果,还要对各个 block 的结果进行二次归约。首先在 gpu 上计算各个 block 的结果

__global__ void  vector_dot_product(float *c, float *a, float *b, int n){
	// alloc shared momery for effictive computing
    __shared__ float tmp_arr[thread_num];
    const int t_idx = threadIdx.x;
    const int b_idx = blockIdx.x;
    const int step = blockDim.x * gridDim.x;
    int tid = t_idx + b_idx * blockDim.x;
    double temp = 0.0;

    while(tid<n) {
        temp += a[tid] * b[tid];
        tid += step;
    }
    tmp_arr[t_idx] = temp;

    // wait all threads for temp
    __syncthreads();
    int i = thread_num / 2;
    while(i != 0) {
        if (t_idx<i) {
            tmp_arr[t_idx] += tmp_arr[t_idx + i];
        }
        __syncthreads();
	    i /= 2;
    }
    if (t_idx == 0) c[b_idx] = tmp_arr[0];
}

然后在 cpu 上对各个 block 的结果进行归约

if (cpu_sum) {
    float temp = 0;
	for (int i = 0; i < block_num; ++i) {
        temp += h_o[i];
    }
	h_o[0] = temp;
}
  • 基于原子操作的向量内积

原子操作 (ATOM) 可以保证线程互斥地访问全局存储或共享存储上的某一数据,因此可以用它来进行简化两次归约,但要注意原子操作结果是直接写回存储,返回值是旧值。

__global__ void vector_dot_product(float *c, float *a, float *b, int n) {
    if ((threadIdx.x == 0) && (blockIdx.x == 0)) {
        c[0] = 0.0;
    }
    __shared__ float tmp[thread_num];
    const int tidx = threadIdx.x;
    const int bidx = blockIdx.x;
    const int t_n = blockDim.x * gridDim.x;
    int tid = bidx * blockDim.x + tidx;
    double temp = 0.0;
    while (tid < n) {
        temp += a[tid] * b[tid];
	    tid += t_n;
    }
    tmp[tidx] = temp;

    __syncthreads();
    int i = blockDim.x / 2;
    while(i != 0) {
        if (tidx < i) {
	        tmp[tidx] += tmp[tidx + i];
		}
		__syncthreads();
		i /= 2;
    }

    if (tidx == 0) {
        atomicAdd(c, tmp[0]);
    }
}

其实就是最后一步不一样,前面是低线程归约的内容

  • 计数法实现多 block 向量内积
__device__ void vector_dot(float *t, volatile float *tmp) {
    const int tidx = threadIdx.x;
    int i = blockDim.x / 2;
    while (i != 0) {
        if (tidx < i) {
	    tmp[tidx] += tmp[tidx + i];
	}
	__syncthreads();
	i /= 2;

	if (tidx == 0) t[0] = tmp[0];
    }
}

__device__ unsigned int lockcount = 0;

__global__ void vector_dot_product(float *c, float *a, float *b, float *t, int n) {
    __shared__ float tmp[thread_num];
    const int tidx = threadIdx.x;
    const int bidx = blockIdx.x;
    const int t_n = blockDim.x * gridDim.x;
    int tid = bidx * blockDim.x + tidx;
    double temp = 0.0;
    while (tid < n) {
        temp += a[tid] * b[tid];
	    tid += t_n;
    }
    tmp[tidx] = temp;

    __syncthreads();

    vector_dot(&t[bidx], tmp);

    __shared__ bool lock;
    __threadfence();
    if (tidx == 0) {
        unsigned int lockiii = atomicAdd(&lockcount, 1);
	    lock = (lockcount==gridDim.x);
    }
    __syncthreads();
    if(lock) {
	   // block_num must less than thread_num
        tmp[tidx] = t[tidx];
	    __syncthreads();
	    vector_dot(c, tmp);
       // lockcount=0;
    }
}

计数法先对每个 block 完成归约过程,保存在中间结果 t 中,然后使用语句 __threadfence(); 后接上原子操作实现栅栏同步,直到最后一个 block 抵达栅栏,读取 t 中的数据完成归约,这里注意,开的 block 必须小于线程数才行,不然 tmp[tidx] = t[tidx]; 会复制不完全。

  • 基于低线程归约的优化

对于低线程的归约,我们可以进行一些优化,主要从减少同步开销和 while 循环展开入手。思路为:一个 warp 是 32 线程,因此有着天然的同步机制,我们可以省去 __syncthreads() ;while 循环的展开,至于什么是 while 循环的展开,我们先看整体优化的代码。

__global__ void vector_dot_product_3_2(float *c, float *a, float *b, float *t, int n) {
    __shared__ float tmp[thread_num];
    const int tidx = threadIdx.x;
    const int bidx = blockIdx.x;
    const int t_n = blockDim.x * gridDim.x;
    int tid = bidx * blockDim.x + tidx;
    double temp = 0.0;
    while (tid < n) {
        temp = __fadd_rn(temp, __fmul_rn(a[tid],b[tid]));
	tid += t_n;
    }
    tmp[tidx] = temp;
    // wait for all thread to complete coping
    __syncthreads();

    //in-place reduction in global memory
    if(blockDim.x>=1024 && tidx <512) tmp[tidx] = __fadd_rn(tmp[tidx], tmp[tidx+512]);
    __syncthreads();
    if(blockDim.x>=512 && tidx <256) tmp[tidx] = __fadd_rn(tmp[tidx], tmp[tidx+256]);
    __syncthreads();
    if(blockDim.x>=256 && tidx <128) tmp[tidx] = __fadd_rn(tmp[tidx], tmp[tidx+128]);
    __syncthreads();
    if(blockDim.x>=128 && tidx <64) tmp[tidx] = __fadd_rn(tmp[tidx], tmp[tidx+64]);
    __syncthreads();
 
    if (tidx < 32) {
        volatile float *tmp_1 = tmp;
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+32]);
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+16]);
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+8]);
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+4]);
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+2]);
        tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+1]);
		if (tidx==0) {
            c[bidx] = tmp_1[0];
		}
    }
}

从上面的代码可以看出,我们首先将每个线程的结果 copy 到 tmp ,然后进行 while 循环展开。最后对剩余 64 个线程进行归约。那我们来介绍一下什么是 while 循环展开

for (int i=0;i<100;i+=4)
{
    a[i+0]=b[i+0]+c[i+0];
    a[i+1]=b[i+1]+c[i+1];
    a[i+2]=b[i+2]+c[i+2];
    a[i+3]=b[i+3]+c[i+3];
}

上面段代码就是 while 循环展开,咋一看是不是没有什么不同,但从判断次数来讲其实是变少了,比起 step 为 1 来说。但是如果这个是在 cpu 上跑,不会有任何区别,因为我们不展开,编译器也会帮我们做。但是目前 cuda 的编译器不会帮我们做这种优化,因此人为展开核函数内的循环,能够提升性能。主要目的是减少指令的消耗。

一些概念的解释

  • 原子操作

原子操作指当一个线程对某个显存变量依次进行“读-计算-写”时,不能插入其他操作。在 GPU 进行并行计算的时候编译器会给每个线程生成 4 个寄存器:”block_index”、”thread_index”、”value”、”counter”。其中,counter 用于存放显存中的变量,对变量进行操作时,先读取到寄存器,再从寄存器进行操作后,写到原来的显存变量,此时需要一个寄存器,而这个寄存器 counter 是所有线程争抢的资源,导致出错。

常用的原子操作如下

函数名 解释
atomicAdd(&value, add_num) value = value + add_num
atomicSub(&value, sub_num) value = value + sub_num
atomicExch(&value, num) value = num
atomicMax(&value, num) value = max(value, num)
atomicMin(&value, num) value = min(value, num)
atomicInc(&value, compare) if (value <= compare) value++ else 0
atomicDec(&value, compare) if (value <= compare) value– else 0
atomicCAS(&value, compare) if (value != compare) value = compare
atomicAnd(&value, add_num) value = value & num
atomicOr(&value, add_num) value = value
atomicXor(&value, add_num) value = value ^ num
需要注意的是,原子操作均写入内存,返回值为旧值。另外原子操作本质是串行的,大量使用会对性能有影响。
  • 关于计算法中的__threadfence;

其实可以简单的把它理解为 block 间的同步,其阻塞 grid 内的线程发出的读写操作完成,一般结合原子操作来减少函数的调用以实现 block 间的同步

[1] write data
[2] __threadfence() 同步
[3] atomic 来进行一个确定都到达栅栏

关于上述的计数法,只用 2 或者 3 都能保证结果正确,但是如果 atomic 写入的是一个长数组就可能出错

  • 关键字 volatile

其中关键字 volatile 的作用是控制变量将结果写到内存里面,而不是其他地方,因为下一行马上要用到。比如 tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+32]);里面前 32 个线程的值被改了,tmp_1[tidx] = __fadd_rn(tmp_1[tidx], tmp_1[tidx+16]); 这一行又要用到,如果写入到缓存里面就会出现读到错误的数据。另外当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。并且可以保证读取的数据立刻被保存。比如

int a = 1;
a = 2;
a = 3;

那么代码可能被编译器转为机器码的时候优化为 a = 3 ,三条转为一条,但是在上述 cuda 代码中是我们不愿意看到的情况,这个时候 volatile 就有效果了。

矩阵乘法

矩阵乘法可以看作内积的集合,也是本篇文章最难的一节。如下图所示,这里 A 矩阵大小为 m 行 l 列,B 矩阵为 l 行 n 列,相乘后得 C 为 m 行 n 列。

这里我们都假设 A、B 为方针,C 也就自然为方阵了。

block 线程循环

我们使用每个 block 计算 C 中的一行,启动 n 个 block 来计算矩阵中的 n 行。

__global__ void mat_mul (float *c, size_t n_c, const float *a, size_t n_a, const float *b, size_t n_b, int n) {
    const int tid = threadIdx.x;
    const int row = blockIdx.x;
    int i, j;

    double tmp = 0.0;
    for (j = tid; j < n; j += blockDim.x) {
        tmp = 0.0;
		for (i = 0; i < n; ++i) {
            tmp += a[row * n_a + i]; * b[i * n_b + j];
		}
		c[row * n_c + j] = tmp;
    }
}

使用 shared memory

这里对于每个 block 来说,要不断访问 A 中的 blockIdx.x 行多次,但是 A 中的数据是在 L1 Cache 上的,如果我们能用共享储存代替全局储存,那么可以对 GPU 计算进行优化,因此这一步是从访存方面入手的。

__global__ void mat_mul (float *c, size_t n_c, const float *a, size_t n_a, const float *b, size_t n_b, int n) {
    extern __shared__ float data[];
    const int tid = threadIdx.x;
    const int row = blockIdx.x;
    int i, j;
    for (i = tid; i < n; i += blockDim.x) {
        data[i] = a[row * n_a + i];
    }
    __syncthreads();
    double tmp = 0.0;
    for (j = tid; j < n; j += blockDim.x) {
        tmp = 0.0;
	for (i = 0; i < n; ++i) {
            tmp += data[i] * b[i * n_b + j];
	}
	c[row * n_c + j] = tmp;
    }
}

我们还可以继续访存优化,对于不是 512 x 512 规整的矩阵,此时矩阵存储不对齐,将会影响访存性能,因此可以使用 padding 的思想,使得矩阵的大小变成规整的矩阵

关于使用 cudaMallocPitch 进行访存对齐,可以理解为我们对矩阵的存储实际上是行优先的,假设全局内存是被划分为 32 byte 为单位的段,那么列数不是 32 的倍数时,访问矩阵每行的首地址时就会和全局内存中分段的首地址不一样,这样的访问属于非合并的。但是我们对每行进行 padding 使其为 32 倍数的列数,这样就对齐了,注意我们不需要管有多少行,padding 只针对列数

size_t p_a, p_b, p_o;
cudaMallocPitch((void**) &d_a, &p_a, sizeof(float) * M, N);
cudaMallocPitch((void**) &d_b, &p_b, sizeof(float) * M, N);
cudaMallocPitch((void**) &d_o, &p_o, sizeof(float) * M, N);

cudaMemcpy2D(d_a, p_a, h_a, sizeof(float) * N, sizeof(float) * N, M, cudaMemcpyHostToDevice);
cudaMemcpy2D(d_b, p_b, h_b, sizeof(float) * N, sizeof(float) * N, M, cudaMemcpyHostToDevice);

mat_mul<<<M, N, sizeof(float) * N>>>(d_o, p_o / sizeof(float), d_a, p_a / sizeof(float), d_b, p_b / sizeof(float), N);

cudaMemcpy2D(h_o, sizeof(float) * N, d_o, p_o, sizeof(float) * N, M, cudaMemcpyDeviceToHost);

这里介绍一下相关的 API

  • cudaMallocPitch (void* devPtr, size_t* pitch, size_t widthInBytes, size_t height)
  • devPtr:开辟矩阵的数据的头指针
    • pitch:分配存储器的宽度,以字节为单位(cuda的返回值)
    • width:分配矩阵的列数,一般是 width x sizeof(type)
    • height:分配矩阵的行数
    • 在设备上分配 widthInBytes * height 字节的线性内存,并返回分配内存的指针 *devPtr。函数将确保在任何给出的行中对应的指针是连续的。
  • cudaMemcpy2D(void dst, size_t dpitch, const void src, size_t spitch,
    size_t width, size_t height, enum cudaMemcpyKind kind)
    • dst: 目的矩阵内存头指针
    • dpitch: dst指向的2D数组中的内存宽度,以字节为单位,是cuda为了读取方便,对齐过的内存宽度,可能大于一行元素占据的实际内存。
    • src:源矩阵内存头指针
    • spitch: src 指向的 2D 数组中的内存宽度,以字节为单位
    • width: src 指向的 2D 数组中一行元素占据的实际宽度。以字节为单位,等于 width*sizeof(type)
    • height: src 指向的2D数组的行数
    • kind:拷贝数据的方向,从src指向的内存区域拷贝数据到dst指向的内存区域

棋盘阵列矩阵乘法

由于访问 B 矩阵仍然处于全局储存,且访问次数没有变化。我们可以考虑使用二维 block 和二维 thread 来对矩阵进行分块。其中一个 block 对应一块,如上图,C 中的一块,由同行号的 A 和同列号的 B 计算得到。而且将可以将小块计算所需数据放入共享存储中,该数据将被复用多次,非常划算。

// chessboard array matmul
__global__ void mat_mul (float *c, size_t n_c, const float *a, size_t n_a, const float *b, size_t n_b, int n) {
    __shared__ float matA[threadnum][threadnum];
    __shared__ float matB[threadnum][threadnum];

    const int tid_c = threadIdx.x;
    const int tid_r = threadIdx.y;
    const int bid_c = blockIdx.x * threadnum;
    const int bid_r = blockIdx.y * threadnum;
    int i, j;
    double res = 0.0;
    for (j = 0; j < n; j += threadnum) {
        matA[tid_r][tid_c] = a[(bid_r+tid_r)*n_a+tid_c+j];
	matB[tid_r][tid_c] = b[(tid_r+j)*n_b+bid_c+tid_c];
	__syncthreads();
	for (i = 0; i < threadnum; ++i) res +=  matA[tid_r][i] * matB[i][tid_c];
	__syncthreads();
    }

    if (tid_r+bid_r<n && tid_c+bid_c<n) {
        c[(bid_r+tid_r)*n_c+bid_c+tid_c] = res;
    }
}

由上述代码可知,C 中每一个小块 (threadnum x threadnum),可以由 A 中的长条 (threadnum x n) 和 B 中的长条 (n x threadnum) 在一个 block 中计算得到。

Kahan’s Summation Formula

我们还可以使用 Kahan’s Summation Formula 来用 float 存储中间结果

__global__ void mat_mul_k (float *c, size_t n_c, const float *a, size_t n_a, const float *b, size_t n_b, int n) {
    __shared__ float matA[threadnum][threadnum];
    __shared__ float matB[threadnum][threadnum];

    const int tid_c = threadIdx.x;
    const int tid_r = threadIdx.y;
    const int bid_c = blockIdx.x * threadnum;
    const int bid_r = blockIdx.y * threadnum;
    int i, j;
    float res = 0.0;
    float comp = 0.0;
    float t = 0.0;
    for (j = 0; j < n; j += threadnum) {
        matA[tid_r][tid_c] = a[(bid_r+tid_r)*n_a+tid_c+j];
        matB[tid_r][tid_c] = b[(tid_r+j)*n_b+bid_c+tid_c];
        __syncthreads();
        for (i = 0; i < threadnum; ++i) {
            comp -= matA[tid_r][i] * matB[i][tid_c];
	    t = res - comp;
	    res = t;
	}
        __syncthreads();
    }

    if (tid_r+bid_r<n && tid_c+bid_c<n) {
        c[(bid_r+tid_r)*n_c+bid_c+tid_c] = res;
    }
}

Kahan’s Summation Formula 是用来防止大数吃掉小数的,比如 float 一般最多只能有 7 位有效数字,此时如果 float a = 123456, float b = 2.323 当两个数求和时,末尾的 2 和 3 就有可能被抹掉了,因为只能有 7 位数字左右。

那么 kahan 求和就是用来避免这种精度损失的问题,先用一个 temp 来记住损失的部分,下次再加回来

float sum = 0;
float c = 0;
float t = 0;
for (int num : nums) {
	float y = num - c; // c 一般为一个非正数
	t = sum + y; // 此步可能存在大数吞小数的情况
				 // 也就是 t 可能比 sum 和 y 的和要小
	c = t - sum - y; // 存在吞数的情况 c 就为负数,由第一步加回来
	sum = t;
}

因此我们的核函数可以修改为

__global__ void mat_mul_k (float *c, size_t n_c, const float *a, size_t n_a, const float *b, size_t n_b, int n) {
    __shared__ float matA[threadnum][threadnum];
    __shared__ float matB[threadnum][threadnum];

    const int tid_c = threadIdx.x;
    const int tid_r = threadIdx.y;
    const int bid_c = blockIdx.x * threadnum;
    const int bid_r = blockIdx.y * threadnum;
    int i, j;
    float res = 0.0;
    float comp = 0.0;
    float t = 0.0;
    for (j = 0; j < n; j += threadnum) {
        matA[tid_r][tid_c] = a[(bid_r+tid_r)*n_a+tid_c+j];
        matB[tid_r][tid_c] = b[(tid_r+j)*n_b+bid_c+tid_c];
        __syncthreads();
        for (i = 0; i < threadnum; ++i) {
            comp -= matA[tid_r][i] * matB[i][tid_c];
	    t = res - comp;
	    res = t;
	}
        __syncthreads();
    }

    if (tid_r+bid_r<n && tid_c+bid_c<n) {
        c[(bid_r+tid_r)*n_c+bid_c+tid_c] = res;
    }
}

小结

  • 数据对齐,主要是指通过 padding 的方式将存储空间对齐
  • 使用二维线程来更好利用共享存储,提升程序性能
  • 如果中间变量的精度不够,可以使用 kahan 求和法来进行精度的补救

矩阵转置

1 D 线程实现转置矩阵

矩阵转置也是十分常见的运算之一,但是对转置进行实现时,读连续写连续一般不可兼得。首先我们可以实现一维的读连续实现转置矩阵:

__global__ void mat_transpose_1_1 (float *c, float *a, int m, int n) {
    CUDA_1D_KERNEL_LOOP(i, n*m) {
        int col = i % m;
        int row = i / m;

        c[col*m+row] = a[row*n+col];
    }
}

同时也易得,写连续的实现转置矩阵:

__global__ void mat_transpose_1_2 (float *c, float *a, int m, int n) {
    CUDA_1D_KERNEL_LOOP(i, n*m) {
        int col = i % m;
        int row = i / m;

        c[row*m+col] = a[col*n+row];
    }
}

2 D 线程实现转置矩阵

当然我们可以使用 2D 线程来实现转置矩阵,但是要注意每个 block 所开线程总数(即 x、y、z 三个维度)不能超过能开总和,因为这个值一般为 1024 比较小:

__global__ void mat_transpose_2_1(float *c, float *a, int m, int n)
{
    const unsigned int xIndex = blockDim.x * blockIdx.x + threadIdx.x;
    const unsigned int yIndex = blockDim.y * blockIdx.y + threadIdx.y;

    if ((xIndex < n) && (yIndex < m))
    {
        unsigned int ind_a = yIndex * n + xIndex;
        unsigned int ind_c = xIndex * m + yIndex;
        c[ind_c] = a[ind_a];
    }
}

由上段程序可以看出依然是一个读连续的实现,如何进行读写都连续的转置矩阵实现呢?我们可以考虑使用上节棋盘矩阵乘法所使用的分块思想来实现。先读连续的写进共享矩阵,然后再写连续的写进输出,如下图所示。

__global__ void mat_transpose_2_2(float *c, float *a, int m, int n)
{
    __shared__ float tmp[BLOCK_DIM][BLOCK_DIM+1];
    unsigned int xIndex, yIndex, bidx, bidy, ind_a, ind_c;

    const int mm = (m + BLOCK_DIM - 1) / BLOCK_DIM;
    const int nn = (n + BLOCK_DIM - 1) / BLOCK_DIM;
    for (bidy=blockIdx.y; bidy<mm; bidy+=gridDim.y)
    {
        for (bidx=blockIdx.x; bidx<nn; bidx+=gridDim.x)
        {
            xIndex = bidx * blockDim.x + threadIdx.x;
            yIndex = bidy * blockDim.y + threadIdx.y;
            if ((xIndex < n) && (yIndex < m))
            {
                ind_a = yIndex * n + xIndex;
                tmp[threadIdx.y][threadIdx.x] = a[ind_a];
            }
            __syncthreads();
            xIndex = bidy * blockDim.x + threadIdx.x;
            yIndex = bidx * blockDim.y + threadIdx.y;
            if ((xIndex < m) && (yIndex < n))
            {
                ind_c = yIndex * m + xIndex;
                c[ind_c] = tmp[threadIdx.x][threadIdx.y];
            }
        }
    }
}

并且我们同时可以应用上一节的技巧 对矩阵进行 padding 来对齐访存,同时我们在使用共享内存的时候,要注意 少量多次循环 使用的原则。


文章作者: L77
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 L77 !
评论
评论
  目录