k均值图像分割的GPU加速

Love The Way You Lie 2022-01-22 00:41 216阅读 0赞

图像分割是指把图像分成各具特性的区域并提取出感兴趣目标的技术和过程,是从图像处理到图像分析的关键步骤。K均值聚类算法是目前最受欢迎和应用最为广泛的聚类分析方法之一。K均值聚类算法用于图像分割具有直观、快速、易于实现的特点。不过当图像很大或者k很大时,采用k均值算法进行图像分割会异常缓慢,所以我们需要对其进行加速。幸运的是,k均值算法最核心的步骤具有很高的并行性,这给我们加速带来了很大遍历。我们既可以通过openmp对其进行加速,也可以利用最新的GPU对其进行加速。在本文中我们着重介绍如何利用GPU加速k均值算法,使其可以更快地进行图像分割。要进行图像分割的原始图片如下:
在这里插入图片描述

k均值算法

k均值算法是最常用和最简单的聚类算法,原理很简单,但是效果还不错。使用k均值算法进行图像分割通常包含如下步骤:

  1. 确定k的个数,可以人为指定也可以通过算法确定;
  2. 选择k个初始的像素点当做最初的类别,可以随机初始化也可以划区域初始化;
  3. 将每一个像素点和k个类别像素点进行距离计算,将其归入距离最小的类别中;
  4. 根据3中计算的每一个像素点类别重新计算k个类别的像素值;
  5. 迭代步骤3和4直到精度足够。

在单次迭代中,步骤3的复杂度为O(nk),其中n为图片大小,k为选择的聚类个数;步骤4的复杂度为O(n+k),所以算法的瓶颈为步骤3。幸运的是,步骤3在计算每个像素点属于哪一类时没有任何依赖性,不同的像素点之间可以并行去做,使其具有极大的加速比。相反,步骤4虽然复杂度低,但是在并行化过程中会遇到极高的写冲突,从而限制了其加速效果。
由于步骤3具有很好的并行性,即使没有GPU我们也可以很方便地通过其他手段进行加速,最容易的就是利用openmp并行化for循环。当计算机的核数很多时,此种方法也可以获得比较明显的收益。由于k均值理解比较容易,我们就不给出串行代码实现了。当k为4时,原始图像分割的效果如下:
在这里插入图片描述
可以看出当k等于4时,我们已经能比较清晰的看出原图的轮廓了。

k均值算法的GPU加速

用GPU加速k均值算法也就是加速步骤3和4,步骤3的kernel称为gen_category,步骤4的kernel称为gen_new_anchor。因为简单来说步骤3就是分类,步骤4就是求新类别。先看一下步骤3的kernel:

  1. __global__ void gen_category(pixel* d_in,pixel* d_anchor,unsigned char* d_category,int len,int k)
  2. {
  3. extern __shared__ pixel shared_anchor[];
  4. if(threadIdx.x<k)
  5. {
  6. shared_anchor[threadIdx.x]=d_anchor[threadIdx.x];
  7. }
  8. __syncthreads();
  9. int idx=blockDim.x*blockIdx.x+threadIdx.x;
  10. if(idx<len)
  11. {
  12. float min_d=9999999.0;
  13. for(int i=0;i<k;i++)
  14. {
  15. float d=manhattan_distance(d_in[idx],shared_anchor[i]);
  16. if(min_d>d)
  17. {
  18. d_category[idx]=i;
  19. min_d=d;
  20. }
  21. }
  22. }
  23. }

输入参数d_in表示图片的所有像素,len表示像素的个数,d_anchor表示k个初始聚类像素点,d_category表示执行完毕之后每个像素点的类别。在启动kernel的时候,我们每个block启动1024线程,然后启动足够多的block使其能覆盖所有的像素点,所以在上面的kernel中没有for循环。block内的每个线程执行操作都一样,就是和k个初始聚类点进行距离比较,然后归类。因为k个初始聚类点会被多次访问,为了加速其访问我们将k个聚类点放入共享内存中,由于k不确定所以我们采用动态方式分配共享内存。
相比之下,步骤4的kernel要复杂很多。因为在求新聚类点的操作中,我们面临n个元素要写入k个元素的问题,实际中n远大于k,所以在步骤4中存在非常严重的写冲突,必须要通过原子操作进行串行化,这就大大限制了步骤4的加速能力。kernel如下:

  1. __device__ int count=0;
  2. __global__ void gen_new_anchor(pixel* d_in,unsigned char* d_category,pixel* d_anchor,
  3. unsigned int* sum_R,unsigned int* sum_G,unsigned int* sum_B,int* total_count,int len,int k)
  4. {
  5. extern __shared__ unsigned int dis_sum[];
  6. const int tid=threadIdx.x;
  7. __shared__ bool isLast;
  8. if(tid<4*k)
  9. {
  10. dis_sum[tid]=0.0;
  11. }
  12. __syncthreads();
  13. int idx=blockDim.x*blockIdx.x+threadIdx.x;
  14. if(idx<len)
  15. {
  16. atomicAdd(&dis_sum[d_category[idx]],d_in[idx].R);
  17. atomicAdd(&dis_sum[k+d_category[idx]],d_in[idx].G);
  18. atomicAdd(&dis_sum[2*k+d_category[idx]],d_in[idx].B);
  19. atomicAdd(&dis_sum[3*k+d_category[idx]],1);
  20. }
  21. __syncthreads();
  22. if(tid<k)
  23. {
  24. atomicAdd(&sum_R[tid],dis_sum[tid]);
  25. atomicAdd(&sum_G[tid],dis_sum[k+tid]);
  26. atomicAdd(&sum_B[tid],dis_sum[2*k+tid]);
  27. atomicAdd(&total_count[tid],dis_sum[3*k+tid]);
  28. __threadfence();
  29. }
  30. if(tid==0)
  31. {
  32. int value=atomicAdd(&count,1);
  33. isLast=(value==gridDim.x-1);
  34. }
  35. __syncthreads();
  36. if(isLast&&tid<k)
  37. {
  38. d_anchor[tid].R=sum_R[tid]/total_count[tid];
  39. d_anchor[tid].G=sum_G[tid]/total_count[tid];
  40. d_anchor[tid].B=sum_B[tid]/total_count[tid];
  41. if(tid==0) count=0;
  42. }
  43. }

上述代码和nvidia官方给出的跨block求和有些相似,每个block利用共享内存先部分累加像素值,然后跨block将部分累加像素值再进行累加得到最终的累加像素值,之后让最后一个block求平均值。为了获得最后一个block,我们用原子操作累加一个在全局内存中的计数变量。当然不止获取最后一个block利用了原子操作,上面每个block中的累加和跨block之间的累加都用了原子加来保证正确性。正是由于大量原子操作的存在导致上述kernel的加速比比较低。如果大家觉得上述代码难以理解,我们可以只启动一个block,将涉及跨block之间同步和累加的操作去掉,这样代码就会简化很多,当然性能也会下降不少,不过该步骤不是瓶颈,所以为了正确性简化代码也是可以接受的。简化之后的kernel如下:

  1. //only one block is executed
  2. __global__ void gen_new_anchor(pixel* d_in,unsigned char* d_category,pixel* d_anchor,int len,int k)
  3. {
  4. extern __shared__ unsigned int dis_sum[];
  5. const int tid=threadIdx.x;
  6. if(tid<4*k)
  7. {
  8. dis_sum[tid]=0.0;
  9. }
  10. __syncthreads();
  11. for(int i=tid;i<len;i+=blockDim.x)
  12. {
  13. atomicAdd(&dis_sum[d_category[i]],d_in[i].R);
  14. atomicAdd(&dis_sum[k+d_category[i]],d_in[i].G);
  15. atomicAdd(&dis_sum[2*k+d_category[i]],d_in[i].B);
  16. atomicAdd(&dis_sum[3*k+d_category[i]],1);
  17. }
  18. __syncthreads();
  19. if(tid<k)
  20. {
  21. d_anchor[tid].R=dis_sum[tid]/dis_sum[3*k+tid];
  22. d_anchor[tid].G=dis_sum[k+tid]/dis_sum[3*k+tid];
  23. d_anchor[tid].B=dis_sum[2*k+tid]/dis_sum[3*k+tid];
  24. }
  25. }

代码在两款GPU上进行了测试:GTX 860m和Tesla P4,CPU为i5四核,针对不同的聚类个数k,代码运行时间如下:
在这里插入图片描述
首先可以看出,当在CPU上运行时,随着k的增大,步骤3时间成平方级别增长。这是因为为了让精度更高,我们迭代的次数和k成正比,所以步骤3的时间和k的平方成正比。其次,GTX 860m是14年的老显卡,计算能力5.0,核心个数为640个,相比CPU依旧可以取得明显的加速,而且加速比随着k的增大而增大,当k等于64时,步骤3的加速比大约为57倍,步骤4为4.5倍。Tesla P4是16年的显卡,也算是比较老了,计算能力6.1,核心个数2560。从核心个数上加速比要比GTX 860m快4倍左右,实际上步骤3要大于4倍,步骤4小于4倍。最后,通过简单的openmp也可以加速步骤3,在四核的笔记本上大约有33%左右的加速,核数越多加速越多。
当k为64时,在CPU上大约需要跑10分钟,当k为128时可以预估时间大约为40分钟,k为255时大约为160分钟,这已经远远超过我们能忍受的极限。在P4上,当k为128时,处理时间为6.8s;k为255时,处理时间为28s均在可接受范围内。所以用GPU加速基于k均值的图像分割确实可以获得非常高的加速比。当k为255时,原图进行图像分割的效果如下:
在这里插入图片描述
可以看出已经和原图没有太大区别,代码下载:http://www.demodashi.com/demo/15470.html

发表评论

表情:
评论列表 (有 0 条评论,216人围观)

还没有评论,来说两句吧...

相关阅读

    相关 svm使用gpu加速

    sklearn里面的svm拿来训练真的贼慢,还不能使用多线程加速,哪怕你的cpu是8核或者16核,训练的时候只使用1核,找了各种方式没有找到 最终发现一个库,叫做thunde

    相关 PyTorch_GPU加速测试

    初步学校pytorch,初步了解gpu 怎样利用gpu 进行运算,参考网上的资料。现在还有有问题,第一次采用计算慢怎样优化的问题。以后学习解决。 import tor

    相关 GPU加速是什么

    GPU加速是什么 by  [被单][Link 1] on  2017-04-11 众所周知,网页不仅应该被快速加载,同时还应该流畅运行,比如快速响应的交互,如丝般顺滑

    相关 k均值图像分割GPU加速

    图像分割是指把图像分成各具特性的区域并提取出感兴趣目标的技术和过程,是从图像处理到图像分析的关键步骤。K均值聚类算法是目前最受欢迎和应用最为广泛的聚类分析方法之一。K均值聚类算