在具有大量零的 cuda 中优化向量矩阵乘法

Optimize vector matrix multiplication in cuda with large number of zeros

我正在使用以下内核来优化向量-矩阵乘法,以应对向量和矩阵都有大量零的情况。使用这个内核 可以减少 这种乘法所花费的时间 到 cublasSgemv 所花费时间的一半 ,对于有的情况超过 90% 为零。但是,它仍然比 Ubuntu 14.04

上的等效 blas gemm 主机调用长得多

vec = 1 x m,mat = m x m 和 prod = 1 x m;都是行优先顺序

米 >= 5000

__global__ void calc_v_m(float *vec, float *mat, float *prod, int m)      
{
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    if(x < m)
    {
        prod[x] = 0;
        for(int i = 0; i < m; i++)
        {
            int offset = i*m + x;
            if( mat[offset] != 0 && vec[i] != 0 )       
                prod[x] += vec[i] * mat[i*m+x];
       }
    }
}

除了像 cuSparse 这样的库之外,还可以做些什么来进一步提高这个内核的性能?

如果此优化与 1.2 的计算能力兼容,那就太好了

谢谢

编辑

更正:产品 = 1 x 米

GPU = Quadro FX 1800M,Cuda v.5.0 Ubuntu 14.04

编辑

使用 i 执行乘法的完整代码。布拉斯岛二。库布拉斯,三。 above kernel for m = 6000. Please enter 0, 当要求输入一个值

#include <iostream>
#include <stdio.h>
#include <time.h>
#include <cblas.h>
#include <cublas_v2.h>
#include <math.h>
using namespace std;

const int m = 6000; 
const int BS = 512; // threads per block
const int NB = ceil((float) m / BS); // number of blocks

__global__ void calc_v_m(float *vec, float *mat, float *prod, int m)  
{
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    if(x < m)
    {
        prod[x] = 0;
        for(int i = 0; i < m; i++)
        {
            int offset = i*m + x;
            if( mat[offset] != 0 && vec[i] != 0 )       
                prod[x] += vec[i] * mat[i*m+x];
        }
    }
}

int main()
{
timespec blas_start, blas_end, cublas_start, cublas_end, opt_start, opt_end;
long totalnsec; //total nano sec
double totalsec, totaltime;
int i, j;

float *A = new float[m]; // 1 x m
float *B = new float[m*m]; // m x m
float *C = new float[m]; // 1 x m

float input;
cout<<"Enter a value to populate the vector (0 to make it sparse) ";
cin>>input;

// input martix A: every 600th element is non-zero i.e 90% zero
for(i = 0; i < m; i++)
{       
    A[i] = input;
    if( i % 600 == 0)    //adjust for sparsity
            A[i] = i;
}

// input matrix B: identity matrix
for(i = 0; i < m; i++)
        for(j = 0; j < m; j++)
                B[j*m + i] = (i==j);

//blas on host
clock_gettime(CLOCK_REALTIME, &blas_start);
cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans, 1, m, m, 1.0f, A, m, B, m, 0.0f, C, m);
//cblas_sgemv(CblasRowMajor, CblasTrans, m, m, 1.0f, B, m, A, 1, 0.0f, C, 1);
clock_gettime(CLOCK_REALTIME, &blas_end);

/* for(i = 0; i < m; i++) printf("%f ", C[i]); */

//cublas section
cudaError_t cudaStat;   
cublasHandle_t handle;
cublasCreate(&handle);
float *A_d, *B_d, *C_d;

cudaStat = cudaMalloc(&A_d, sizeof(float)*m);
if(cudaStat != cudaSuccess) printf("Error Allocating Memory for A_d\n");

cudaStat = cudaMalloc(&B_d, sizeof(float)*m*m);
if(cudaStat != cudaSuccess) printf("Error Allocating Memory for B_d\n");

cudaStat = cudaMalloc(&C_d, sizeof(float)*m);
if(cudaStat != cudaSuccess) printf("Error Allocating Memory for C_d\n");

cudaMemcpy(A_d, A, sizeof(float)*m, cudaMemcpyHostToDevice);
cudaMemcpy(B_d, B, sizeof(float)*m*m, cudaMemcpyHostToDevice);

float alpha = 1.0f, beta = 0.0f;

cudaDeviceSynchronize();
clock_gettime(CLOCK_REALTIME, &cublas_start);
cublasSgemv(handle, CUBLAS_OP_N, m, m, &alpha, B_d, m, A_d, 1, &beta, C_d, 1);
cudaDeviceSynchronize();
clock_gettime(CLOCK_REALTIME, &cublas_end);

cudaMemcpy(C, C_d, sizeof(float)*m, cudaMemcpyDeviceToHost);    
/* for(i = 0; i < m; i++) printf("%f ", C[i]); */ 

// Call kernel having Optimization for Zeros
cudaDeviceSynchronize();
clock_gettime(CLOCK_REALTIME, &opt_start);
/////////////////// call kernel //////////////////
calc_v_m<<<NB, BS>>>(A_d, B_d, C_d,  m);
//////////////////////////////////////////////////
cudaDeviceSynchronize();
clock_gettime(CLOCK_REALTIME, &opt_end);

cudaMemcpy(C, C_d, sizeof(float)*m, cudaMemcpyDeviceToHost);    
/*for(i = 0; i < m; i++) printf("%f ", C[i]); */

// Print times
// blas time
totalsec = (double)blas_end.tv_sec - (double)blas_start.tv_sec;
totalnsec = blas_end.tv_nsec - blas_start.tv_nsec;
if(totalnsec < 0)
{
    totalnsec += 1e9;
    totalsec -= 1;
}   
totaltime = totalsec + (double)totalnsec*1e-9;
cout<<"blas Time = "<< totaltime << "\n";

//cublas time
totalsec = (double)cublas_end.tv_sec - (double)cublas_start.tv_sec;
totalnsec = cublas_end.tv_nsec - cublas_start.tv_nsec;
if(totalnsec < 0)
{
    totalnsec += 1e9;
    totalsec -= 1;
}
totaltime = totalsec + (double)totalnsec*1e-9;
cout<<"cublas Time = "<< totaltime << "\n";

//Optimized Kernel Time
totalsec = (double)opt_end.tv_sec - (double)opt_start.tv_sec;
totalnsec = opt_end.tv_nsec - opt_start.tv_nsec;
if(totalnsec < 0)
{
     totalnsec += 1e9;
     totalsec -= 1;
}
totaltime = totalsec + (double)totalnsec*1e-9;
cout<<"Opt Kernel Time = "<< totaltime << "\n";

return 0;
}

结果

$ nvcc -arch=sm_12 blascomp.cu -o blascomp.o -lblas -lcublas
$ ./blascomp.o
Enter a value to populate the vector (0 to make it sparse) 0
blas Time = 0.000105207
cublas Time = 0.0070294
Opt Kernel Time = 0.00642797

至少在我的系统上,对于这种情况,blas 仍然是最快的

如果每个“第 1200 个”元素而不是“第 600 个”元素都设置为 0,事情会变得更加有趣

Enter a value to populate the vector (0 to make it sparse) 0
blas Time = 7.84e-05
cublas Time = 0.00698783
Opt Kernel Time = 0.00643042

这里要认识到的重要一点是,您所关心的 gemv 操作基本上是 GPU 上的内存吞吐量限制,而不是计算吞吐量限制。这意味着 "optimisation" 如您在内核中所示:

__global__ void calc_v_m(float *vec, float *mat, float *prod, int m)  
{
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    if(x < m)
    {
        prod[x] = 0;
        for(int i = 0; i < m; i++)
        {
            int offset = i*m + x;
            if( mat[offset] != 0 && vec[i] != 0 )       
                prod[x] += vec[i] * mat[i*m+x];
        }
    }
}

根本不是真正的优化,仅仅是因为内存事务是内核中的性能瓶颈,而不是浮点运算,无论乘加运算是否存在,您的代码都必须执行大部分内存事务是否会因零检出而执行

考虑以下大致相同代码的检测版本:

__constant__ float cvec1[2];
__global__ void 
__launch_bounds__(512,4)    
calc_v_m1(const float* __restrict__ vec,
          const float* __restrict__ mat, 
          float* __restrict__ prod, 
          int m,
          int do_reads = 1,
          int do_write = 1)
{
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    if(x < m)
    {
        float res = 0;
        float mval = cvec1[0], vval = cvec1[1];
#pragma unroll 8
        for(int i = 0; i < m; i++)
        {
            int offset = i*m + x;
            if (do_reads) {
                mval = mat[offset];
                vval = vec[i];
            } 
            res += mval * vval;
        }
        if (do_write) prod[x] = res;
    }
}

这里我添加了两个可选参数来控制内核是否从全局内存加载,以及内核是否存储到全局内存。这使我能够独立地量化内存加载、计算和内存存储对性能的影响。使用您的测试代码的结果具有指导意义:

Function                            nvprof time
-----------------------------------------------
cublasSgemv                         942.75us
calc_v_m                            2798.4us
calc_v_m1(do_reads=1, do_write=1)   962.40us
calc_v_m1(do_reads=1, do_write=0)   970.40us
calc_v_m1(do_reads=0, do_write=1)   55.166us
calc_v_m1(do_reads=0, do_write=0)   55.102us

[使用 CUDA 7.5 发布工具链和 CUBLAS 7.5 库在 GTX970 上完成的所有基准测试]

排名不分先后:

  • 完整检测的内核运行时间在等效 CUBLAS 调用的百分之几内
  • 从全局内存中获取内存是瓶颈
  • 内核中的实际计算只占内核运行时间的5%
  • CUDA 中写入操作的 "fire-and-forget" 性质意味着写入延迟对吞吐量没有显着影响。
  • 您的 "optimised" 内核比 CUBLAS 或检测内核慢得多,可能是因为您所引入的只是分支发散,而没有解决内核瓶颈的根源(内存加载的延迟)。

有条件地执行 FMAD 操作唯一有意义的情况是在内存几乎为零延迟且浮点吞吐量受到严格限制的体系结构中。 GPU 绝对不属于这一类。

唯一的其他优化选项是利用 先验 关于 LHS 矩阵中稀疏模式的信息来消除读取零条目的需要。这正是稀疏矩阵格式和线性代数代码旨在适应的。