在上一篇文章中,我们讲了高斯滤波以及分离高斯滤波的原理与C++实现。本文将在此基础上,分别详细讲解使用SSE指令和CUDA来对分离高斯滤波算法的优化加速。

一、SSE指令优化

我们知道,SSE指令优化的核心思路是在一条CPU指令内同时对4个浮点数进行相同的运算。所以可以使用SSE指令优化来加速计算加权和,每次循环计算窗口内同一行的8个像素点的加权和。显而易见,这就要求窗口的列数不能小于8,如果列数小于8则不适用SSE指令优化,不过,当窗口列数小于8时,窗口尺寸是相当小的,按正常那样每次循环计算一个像素点的像素值与权重的乘积,然后将乘积累加即可。

对于所取窗口的某一行,使用SSE指令加速计算的示意图所下图所示。对于像素窗口与权重矩阵,均每一次取同一行的8个值,并将其分成两组,每组的4个点构成一个SSE指令的__m128数据变量,然后使用SSE指令对__m128变量进行加权和运算。

图像resize加速 图像处理加速_图像resize加速

结合上图,SSE指令计算过程主要分为以下几步:

1. 加载数据到__m128变量中:

X0变量:x3, x2, x1, x0

X1变量:x7, x6, x5, x4

K0变量:k3, k2, k1, k0

K1变量:k7, k6, k5, k4

2. SSE指令计算:

M0=X0*K0 --> x3*k3, x2*k2, x1*k1, x0*k0

M1=X1*K1 --> x7*k7, x6*k6, x5*k5, x4*k4

M=M0+M1 --> x3*k3+x7*k7, x2*k2+x6*k6, x1*k1+x5*k5, x0*k0+x4*k4

3. 使用SSE指令将变量M累加到另一个__m128变量中:

s=s+M

4. 循环结束之后,将变量s中的4个浮点数相加,就是最后的加权和:

sum=s[0]+s[1]+s[2]+s[3]

话不多说,下面直接上代码:

void gaussian_fiter_XY_SSE(Mat src, Mat &dst)
{
  Mat src_board;
  copyMakeBorder(src, src_board, Gaussian_Size_2, Gaussian_Size_2, Gaussian_Size_2, Gaussian_Size_2, BORDER_REFLECT);   //扩充边缘
  Mat dstx = Mat::zeros(src_board.size(), CV_32FC1);
  dst = Mat::zeros(src.size(), CV_8UC1);


  if (Gaussian_Size < 8)  //如果窗口列数小于8则不适用SSE优化,正常计算即可
  {
    for (int i = 0; i < src_board.rows; i++)    //x方向一维卷积
    {
      for (int j = Gaussian_Size_2; j < src_board.cols - Gaussian_Size_2; j++)
      {
        float sum = 0.0;


        for (int x = 0; x < Gaussian_Size; x++)
        {
          sum += src_board.ptr<uchar>(i)[j - Gaussian_Size_2 + x] * Gaussian_Ker_XY[x];
        }
        dstx.ptr<float>(i)[j] = sum;
      }
    }


    for (int i = Gaussian_Size_2; i < src_board.rows - Gaussian_Size_2; i++)    //y方向一维卷积
    {
      for (int j = Gaussian_Size_2; j < src_board.cols - Gaussian_Size_2; j++)
      {
        float sum = 0.0;


        for (int y = 0; y < Gaussian_Size; y++)
        {
          sum += dstx.ptr<float>(i - Gaussian_Size_2 + y)[j] * Gaussian_Ker_XY[y];
        }
        dst.ptr<uchar>(i - Gaussian_Size_2)[j - Gaussian_Size_2] = (uchar)sum;
      }
    }
  }
  else  
  {
    for (int i = 0; i < src_board.rows; i++)    //x方向一维卷积
    {
      for (int j = Gaussian_Size_2; j < src_board.cols - Gaussian_Size_2; j++)
      {
        float sum = 0.0;
        __m128 m_sum = _mm_setzero_ps();   //累加变量
        uchar *p = src_board.ptr<uchar>(i);


        int x = 0;
        for (; x < Gaussian_Size-8; x+=8)
        {
          int idx = j - Gaussian_Size_2 + x;
          //加载变量X0
          __m128 m_src_L = _mm_set_ps(p[idx+3], p[idx+2], p[idx+1], p[idx]);
          //加载变量X1
          __m128 m_src_H = _mm_set_ps(p[idx+7], p[idx+6], p[idx+5], p[idx+4]);
          //加载变量K0
          __m128 g_L = _mm_loadu_ps(&Gaussian_Ker_XY[x]);
          //加载变量K1
          __m128 g_H = _mm_loadu_ps(&Gaussian_Ker_XY[x+4]);


          __m128 dx_L = _mm_mul_ps(m_src_L, g_L);  //计算M0=X0*K0
          __m128 dx_H = _mm_mul_ps(m_src_H, g_H);  //计算M1=X1*K1
          //计算s = s + M = s + (M0 + M1)
          m_sum = _mm_add_ps(m_sum, _mm_add_ps(dx_L, dx_H));
        }


        float *q = (float *)&m_sum;
        //计算sum=s[0]+s[1]+s[2]+s[3]
        sum = (q[0] + q[1]) + (q[2] + q[3]);


        for (; x < Gaussian_Size; x++)
        {
          sum += src_board.ptr<uchar>(i)[j - Gaussian_Size_2 + x] * Gaussian_Ker_XY[x];
        }


        dstx.ptr<float>(i)[j] = sum;
      }
    }


    for (int i = Gaussian_Size_2; i < src_board.rows - Gaussian_Size_2; i++)    //y方向一维卷积
    {
      for (int j = Gaussian_Size_2; j < src_board.cols - Gaussian_Size_2; j++)
      {
        float sum = 0.0;
        __m128 m_sum = _mm_setzero_ps();


        int y = 0;
        for (; y < Gaussian_Size - 8; y += 8)
        {
          int idx = i - Gaussian_Size_2 + y;
          //列向取值
          float *p0 = dstx.ptr<float>(idx);
          float *p1 = p0 + dstx.cols;
          float *p2 = p1 + dstx.cols;
          float *p3 = p2 + dstx.cols;
          float *p4 = p3 + dstx.cols;
          float *p5 = p4 + dstx.cols;
          float *p6 = p5 + dstx.cols;
          float *p7 = p6 + dstx.cols;
          __m128 m_src_L = _mm_set_ps(p3[j], p2[j], p1[j], p0[j]);
          __m128 m_src_H = _mm_set_ps(p7[j], p6[j], p5[j], p4[j]);
          __m128 g_L = _mm_loadu_ps(&Gaussian_Ker_XY[y]);
          __m128 g_H = _mm_loadu_ps(&Gaussian_Ker_XY[y+4]);


          __m128 dy_L = _mm_mul_ps(m_src_L, g_L);
          __m128 dy_H = _mm_mul_ps(m_src_H, g_H);


          m_sum = _mm_add_ps(m_sum, _mm_add_ps(dy_L, dy_H));


        }


        float *q = (float *)&m_sum;
        sum = (q[0] + q[1]) + (q[2] + q[3]);


        for (; y < Gaussian_Size; y++)
        {
          sum += dstx.ptr<float>(i - Gaussian_Size_2 + y)[j] * Gaussian_Ker_XY[y];
        }
        dst.ptr<uchar>(i - Gaussian_Size_2)[j - Gaussian_Size_2] = (uchar)sum;
      }
    }
  }
  
}

取59*59窗口,σ=1.0,对496*472的图像进行滤波,上述代码耗时约43.01 ms,而上篇文章中的一维分离滤波代码则耗时约54.74 ms。可以看到,耗时有所减少,不过减少的并不多。

二、CUDA优化

1. GPU与CUDA简介

GPU的概念最早由NVIDIA公司提出。GPU即图形处理器,又称为显示芯片,是一种专门用于图像数据处理的微处理器。随着GPU的发展和增强,GPU通用计算技术逐渐引起业界关注并得到广泛应用,GPU在并行计算和浮点运算等计算任务上,可提供优于CPU十倍甚至上百倍的性能。

CUDA是NVIDIA公司在2007年推出的并行编程模型和计算平台,它利用GPU强大计算能力实现复杂数据的并行运算,从而显著提高计算性能和计算效率。可以把CUDA看作是对GPU硬件计算功能进行封装的程序库,该程序库包含GPU内部的并行计算引擎和CUDA的指令集架构,开发人员通过调用CUDA程序库中的API接口,可以方便地在CPU和GPU之间互相传输数据,并控制GPU底层硬件进行并行运算。

CUDA提供host-device编程模式,即CPU作为host,GPU作为device,通常是先把数据从host拷贝到device,在device利用多线程并行处理数据,等待所有线程处理完毕,再把处理后的数据从device拷贝回host中,一个典型的CUDA计算任务流程如下图所示。

图像resize加速 图像处理加速_CUDA_02

2. 线程模型简介

假如图像有row*col个像素点,窗口尺寸为(2n+1)*(2n+1),对于每一个像素点都要计算其邻域窗口的像素加权和,那么总共需要计算row*col个邻域窗口的像素加权和,所以我们的并行优化思路为:对于行方向加权和与列方向加权和,都开启row*col个线程,每个线程对应一个像素点,其负责计算该点的邻域窗口像素加权和,且所有线程并行计算。如下图所示:

图像resize加速 图像处理加速_CUDA_03

CUDA的线程模型如下图所示。Host启动内核(kernel)在device上执行,1个内核执行时启动的所有线程组成一个线程网格(grid),一个网格包含多个线程块(block),然后每个线程块又包含多个线程。

图像resize加速 图像处理加速_图像resize加速_04

由于每一个线程块包含的最大线程数是有限的(根据NVIDIA显卡型号不一样,常见的限制有512个和1024个),编程时需要分配多个线程块,然后每个线程块分配多个线程才能满足总线程数要求。对于二维图像,需要对像素点的x坐标和y坐标分别通过线程块号与线程号进行索引寻址,因此适合把线程块和线程都设置成二维坐标索引,假设一个线程块的索引为(blockIdx_x,blockIdx_y),该线程块中一个线程的索引为(threadIdx_x,threadIdx_y),且在线程网格中线程块在x方向和y方向的维度分别为blockDim_x和blockDim_y,那么该线程对应图像中的点坐标(x, y)计算如下式:

图像resize加速 图像处理加速_权重_05

3. 纹理内存简介

纹理内存是GPU中的一种只读存储器,其使用方式为将某一段全局内存绑定到纹理内存,这段全局内存通常的表现形式为一维、二维或三维CUDA数组,然后通过读取纹理内存(也称为纹理拾取)来获取全局内存的数据。纹理内存专门为图像纹理渲染而设计,对非对齐访问和随机访问具有良好的加速效果,然而全局内存的访问在GPU的所有存储类型中是最慢的。

对于每一个线程,计算加权和时对图像数据与权重的访问均为2n+1次,访问率还是比较频繁的,如果把图像数据和权重都存储在全局内存中并直接访问全局内存,会明显增加计算耗时。因此可以把存储图像数据和权重的GPU全局内存绑定到纹理内存,然后通过纹理拾取来读取,可进一步减少时间开销,如下图所示。

图像resize加速 图像处理加速_CUDA_06

4. 代码实现

计算一维权重代码:

#define GAUSS_KSIZE 59
#define GAUSS_KSIZE_2 (GAUSS_KSIZE>>1)
float gauss_XY_ker[GAUSS_KSIZE];
void getGaussianArray_CUDA(float sigma)   //计算高斯滤波的卷积核
{
  float sum = 0.0f;
  const float sigma_2 = sigma*sigma;
  const float a = 1.0 / (2 * 3.14159*sigma_2);


  for (int i = 0; i < GAUSS_KSIZE; i++)
  {
    float dx = i - GAUSS_KSIZE_2;
    gauss_XY_ker[i] = a*exp(-dx*dx / (2.0*sigma_2));
    sum += gauss_XY_ker[i];
  }
  sum = 1.0 / sum;
  for (int i = 0; i < GAUSS_KSIZE; i++)
  {
    gauss_XY_ker[i] *= sum;
  }
}

行方向一维加权和的核函数:

__global__ void gaussian_filterX(float *dst, int row, int col)
{
  int x = blockIdx.x * blockDim.x + threadIdx.x;    //col
  int y = blockIdx.y * blockDim.y + threadIdx.y;    //row


  if (x < col && y < row)
  {
    int index = y*col + x;
    float sum = 0.0;
    if (x >= GAUSS_KSIZE_2 && x < col - GAUSS_KSIZE_2 && y >= GAUSS_KSIZE_2 && y < row - GAUSS_KSIZE_2)
    {
      int x_g = x - GAUSS_KSIZE_2;
      for (int l = 0; l < GAUSS_KSIZE; l++)
      {
        sum += tex2D(tex_src, (float)(x_g + l), (float)y) * tex1Dfetch(tex_ker, l);
      }
    }
    else
    {
      sum = (float)tex2D(tex_src, (float)x, (float)y);
    }
    dst[index] = sum;
  }
}

列方向一维加权和的核函数:

__global__ void gaussian_filterY(uchar *dst, int row, int col)
{
  int x = blockIdx.x * blockDim.x + threadIdx.x;    //col
  int y = blockIdx.y * blockDim.y + threadIdx.y;    //row


  if (x < col && y < row)
  {
    int index = y*col + x;
    float sum = 0.0;
    if (x >= GAUSS_KSIZE_2 && x < col - GAUSS_KSIZE_2 && y >= GAUSS_KSIZE_2 && y < row - GAUSS_KSIZE_2)
    {
      int y_g = y - GAUSS_KSIZE_2;
      for (int l = 0; l < GAUSS_KSIZE; l++)
      {
        sum += tex2D(tex_dstx, (float)x, (float)(y_g + l)) * tex1Dfetch(tex_ker, l);
      }
    }
    else
    {
      sum = tex2D(tex_dstx, (float)x, (float)y);
    }
    dst[index] = (uchar)sum;
  }
}

主体函数:

void gaussian_fiter_cuda(Mat src, Mat &dst)
{
  Mat src_board;
  //边缘扩展
  copyMakeBorder(src, src_board, GAUSS_KSIZE_2, GAUSS_KSIZE_2, GAUSS_KSIZE_2, GAUSS_KSIZE_2, BORDER_REFLECT);   //扩充边缘


  dst = Mat::zeros(src.size(), CV_8UC1);


  const int row = src_board.rows;
  const int col = src_board.cols;
  const int img_size_float = row*col*sizeof(float);


  //


  float *dstx_cuda;
  uchar *dst_cuda;
  float *ker_cuda;
  //申请全局内存
  cudaMalloc((void**)&dstx_cuda, img_size_float);
  cudaMalloc((void**)&dst_cuda, row*col);
  cudaMalloc((void**)&ker_cuda, GAUSS_KSIZE*sizeof(float));
  //将权重拷贝到全局内存
  cudaMemcpy(ker_cuda, gauss_XY_ker, GAUSS_KSIZE*sizeof(float), cudaMemcpyHostToDevice);


  //
  //将存储权重的全局内存绑定到纹理内存
  cudaBindTexture(0, tex_ker, ker_cuda);    //绑定一维纹理


  //
  
  cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc<uchar>();//声明数据类型
  cudaArray *cuArray_src;
  cudaMallocArray(&cuArray_src, &channelDesc, col, row);  //分配大小为col*row的CUDA数组
  //将图像数据拷贝到CUDA数组
  cudaMemcpyToArray(cuArray_src, 0, 0, src_board.data, row*col, cudaMemcpyHostToDevice);


  tex_src.addressMode[0] = cudaAddressModeWrap;//寻址方式
  tex_src.addressMode[1] = cudaAddressModeWrap;//寻址方式 如果是三维数组则设置texRef.addressMode[2]
  tex_src.normalized = false;//是否对纹理坐标归一化
  tex_src.filterMode = cudaFilterModePoint;//纹理的滤波模式:最近点取样和线性滤波  cudaFilterModeLinear
  cudaBindTextureToArray(&tex_src, cuArray_src, &channelDesc);  //纹理绑定,CUDA数组和纹理参考的连接


  //


  cudaChannelFormatDesc channelDesc1 = cudaCreateChannelDesc<float>();//声明数据类型
  cudaArray *cuArray_dstx;
  cudaMallocArray(&cuArray_dstx, &channelDesc1, col, row);  //分配大小为col*row的CUDA数组


  tex_dstx.addressMode[0] = cudaAddressModeWrap;//寻址方式
  tex_dstx.addressMode[1] = cudaAddressModeWrap;//寻址方式 如果是三维数组则设置texRef.addressMode[2]
  tex_dstx.normalized = false;//是否对纹理坐标归一化
  tex_dstx.filterMode = cudaFilterModePoint;//纹理的滤波模式:最近点取样和线性滤波  cudaFilterModeLinear
  cudaBindTextureToArray(&tex_dstx, cuArray_dstx, &channelDesc1);  //纹理绑定,CUDA数组和纹理参考的连接


  //


  dim3 Block_G(16, 16);
  dim3 Grid_G((col + 15) / 16, (row + 15) / 16);
  //调用行方向加权和kernel函数
  gaussian_filterX<<<Grid_G, Block_G>>>(dstx_cuda, row, col);
  //将行方向加权和的结果拷贝到全局内存
  cudaMemcpyToArray(cuArray_dstx, 0, 0, dstx_cuda, img_size_float, cudaMemcpyDeviceToDevice);
  //调用列方向加权和kernel函数
  gaussian_filterY<<<Grid_G, Block_G>>>(dst_cuda, row, col);


  //
  //将滤波结果从GPU拷贝到CPU
  cudaMemcpy(src_board.data, dst_cuda, row*col, cudaMemcpyDeviceToHost);
  src_board(Rect(GAUSS_KSIZE_2, GAUSS_KSIZE_2, src.cols, src.rows)).copyTo(dst);


  //


  cudaFree(dstx_cuda);    //释放全局内存
  cudaFree(dst_cuda);
  cudaFree(ker_cuda);
  cudaFreeArray(cuArray_src);   //释放CUDA数组
  cudaFreeArray(cuArray_dstx);
  cudaUnbindTexture(tex_src);   //解绑全局内存
  cudaUnbindTexture(tex_dstx);
  cudaUnbindTexture(tex_ker);
}

同样取59*59窗口,σ=1.0,对496*472的图像进行滤波,运行上述代码耗时约1.66 ms,可以看到,耗时相对于SSE优化的43.01 ms大大减小,因此使用CUDA并行加速的效果还是非常明显的。