1. Kmeans 步骤
常规的 Kmeans 步骤:
1. 初始化聚类中心
2. 迭代
1. 计算每个样本与聚类中心的欧式距离
2. 根据样本与聚类中心的欧式距离更新每个样本的类标签
3. 根据类标签更新聚类中心
本文中并行化的 Kmeans 的步骤:
- 初始化每个样本的类标签
- 迭代
- 统计每一类的样本和
- 统计每一类的样本个数
- 计算每一类的聚类中心:样本和 / 样本个数
- 计算每个样本与每个聚类中心的欧式距离
- 根据每个样本与聚类中心的欧式距离更新样本的类标签
如下所示:
/* 样本聚类索引的初始化*/
KmeansCUDA_Init_ObjClusterIdx<<<>>>();
for (int i = 0; i < maxKmeansIter; i++)
{
/* 统计每一类的样本和 */
KmeansCUDA_Update_Cluster<<<>>>();
/* 统计每一类的样本个数 */
KmeansCUDA_Count_objNumInCluster<<<>>>();
/* 聚类中心平均 = 样本和 / 样本个数 */
KmeansCUDA_Scale_Cluster<<<>>>();
/* 计算每个样本与每个聚类中心的欧式距离 */
KmeansCUDA_distOfObjAndCluster<<<>>>();
/* 根据每个样本与聚类中心的欧式距离更新样本的类标签 */
KmeansCUDA_Update_ObjClusterIdx<<<>>>();
}
2. 完全分解
不同的数据,可能会有不同的优化方式,所以本文首先给出本文的实验条件:
- 数据
将一个 256 * 256 * 3 的三维图像进行块划分,块的大小为 5 * 5 * 3,取块的间隔为 1 个像素,所以总的块数(样本数)为(256 - 5 + 1)*(256 - 5 + 1)= 63504 个,样本的维度为 5 * 5 * 3 = 75。聚类的个数为 80。 - 环境
GPU:NVIDIA GTX980,2048 个核
CUDA版本:6.5
第一部分对应的几个函数的详细分解方式如下所示:
KmeansCUDA_Init_ObjClusterIdx
函数
由于只执行一次,且计算量非常小,故不作为优化的重点。
线程块维度:256
线程格维度:(63504 + 256 - 1) / 256 = 249
每个线程负责初始化一个样本的类标签。
KmeansCUDA_Update_Cluster
函数
如果每个类的样本都放在连续空间,那么此函数可以使用类似于规约求和的方式就可以实现,且效率很高。但是此处每个类是分散的,所以换一种方式:
线程块维度:(16,16)
线程格维度:((75 + 16 - 1) / 16, (63504 + 16 - 1) / 16) = (5, 3969)
每个线程负责一个样本中的一个数据,此处要使用原子操作,因为多个线程可能同时写一个聚类中心的数据。当然,也可以按下列的方式划分线程块和线程格:
线程块维度:256
线程格维度:(63504 + 256 - 1) / 256 = 249
每个线程负责更新一个样本,但是此时的效率不高:
第一种方式运行 14 次的时间为: 6.686ms
第二种方式运行 14 次的时间为:14.142ms
KmeansCUDA_Count_objNumInCluster
函数
与KmeansCUDA_Init_ObjClusterIdx
函数类似,计算量很少,每个负责处理一个样本的计数,只有一个原子操作的加法,所以依旧采用一维的线程方式:
线程块维度:256
线程格维度:(63504 + 256 - 1) / 256 = 249
由于会有 63504 个线程写 80(类数)个位置,所以会存在许多冲突,所以此处对其进行优化,开辟线程数变为:
线程块维度:1024
线程格维度:(63504 + 1024 - 1) / 1024 = 63
此时,在每个块内申请 80 个整数大小的共享内存,先在块内进行统计,最后再写到全局内存,而不是像第一种方式那样,直接写全局内存,这样就避免了很多的冲突,两种方式的时间对比为:
第一种方式运行 14 次的时间为:733.3us
第二种方式运行 14 次的时间为:61.44us
从上可以看出,通过减少冲突,使用共享内存,可以加速 12 倍左右。
KmeansCUDA_Scale_Cluster
函数
此函数用于对每个样本求和之后的取平均操作,计算量极其少,不是优化的重点,开辟二维线程块线程块维度:(16,16)
线程格维度:((75 + 16 - 1) / 16, (80 + 16 - 1) / 16) = (5, 5)
每个线程负责更新聚类中心中的一个数。
KmeansCUDA_distOfObjAndCluster
函数
此函数是优化的重点,因为 Kmeans 的绝大部分计算量都集中在求每个样本与每个聚类中心的欧式距离,在此也开辟二维线程块:
线程块维度:(16,16)
线程格维度:((80 + 16 - 1) / 16, (63504 + 16 - 1) / 16) = (5, 3969)
每个线程负责计算一个样本与一个聚类中心的欧式距离。考虑到此函数的计算过程中聚类中心是不变的,可以考虑使用常量内存,以此加快对聚类中心的访问速度(此方案称为方案二)。另外,考虑到此函数的计算方式与矩阵乘法类似,可以考虑使用共享内存,每个线程块的任务是计算 16 个样本与 16 个聚类中心的距离(此方案称为方案三)将16 个样本与 16 个聚类中心的数据存到共享内存中(每一个线程块的共享内存为:32 * 75 * 4 / 1024 = 9.375kb 小于 每块最大可以使用量 48kb,每个 SM 最多调度 2048 个线程,也就是最多 8 个线程块,所以每个 SM 使用的共享内存为 75kb,小于 GTX980 每块 SM 的 96kb,综上,共享内存是够用的)。三种方案的执行14次的时间为:
方案一:183.562ms
方案二:243.298ms
方案三:25.213ms
从上可以看出,使用共享内存的计算时间远远小于未使用共享内存的计算时间,方案三相较于方案一加速 7 倍多,考虑到是计算量最大的模块加速 7 倍多,所以加速效果相当理想。但是方案二使用常量内存的计算时间反而比不使用常量内存的时间还长,这是因为聚类中心的数据大小为:80 *75*4/1024 = 23.4375 kb,虽然 GTX980 的最大可用常量内存为 64 kb,但是在每个 SM 共共常量内存使用的高速缓存只有 10 kb(常量内存是借助于高速缓存来实现快速访问,对于计算能力小于 5.0 的设备,此值为 8 kb),也就是说不够缓存所有的聚类中心,导致反复缓存,缓存命中的效率极低。
__shared__ float objShared[BLOCKSIZE][OBJLENGTH]; // 存样本
__shared__ float cluShared[BLOCKSIZE][OBJLENGTH]; // 存聚类中心
for (int xidx = threadIdx.x; xidx < OBJLENGTH; xidx += BLOCKSIZE)
{
int index = myParameter.objLength * threadIdx.y + xidx;
objShared[threadIdx.y][xidx] = objects[index];
cluShared[threadIdx.y][xidx] = clusters[index];
}
上述代码中将数据从全局内存拷贝到共享内存中的方式如下图所示,每次读同一种颜色的块,经多次循环之后全部读入共享内存中。
上述访问方式会人为的将连续的内存区域分成不连续的访问,将无法实现合并访问,进而无法隐藏内存访问的延迟,导致效率不高,因此对其进行优化。
__shared__ float objShared[OBJLENGTH * BLOCKSIZE]; // 存样本
__shared__ float cluShared[OBJLENGTH * BLOCKSIZE]; // 存聚类中心
for (int index = BLOCKSIZE * threadIdx.y + threadIdx.x; index < OBJLENGTH * BLOCKSIZE; index = BLOCKSIZE * BLOCKSIZE + index)
{
objShared[index] = objects[index];
cluShared[index] = clusters[index];
}
读取方式如下图所示:
优化前后的时间对比为:
优化前:25.213ms
优化后:24.173ms
虽然效率没有明显提升,但是还是很可观的。
KmeansCUDA_Update_ObjClusterIdx
函数
用于寻找每个样本与所有聚类中心中距离最小的,将当前样本划归到该类。可以开辟一维线程:
线程块维度:256
线程格维度:(63504 + 256 - 1) / 256 = 249
每个线程用于查找当前样本对应的最短距离(共 80 个)。因为求最小与规约类似,而上述方式的每个样本却是完全串行的方式,所以对其进行优化,开辟二维线程:
线程块维度:(16,16)
线程格维度:((1, (63504 + 16 - 1) / 16) = (1, 3969)
每个线程块用于计算 16 个样本的最小距离,也就是说用 16 个线程来计算原来 1 个线程的工作(查找 80 个数的最小值)。
最后,把剩余两个元素的较小值作为最短距离,优化前后的时间对比为:
优化前:12.725ms
优化后:1.887ms
优化后加速 7 倍左右,效果相当可观。
3. 结果
如下图所示,是优化的最终版本的 visual profile 结果,可见,依旧是计算样本与聚类中心的距离最耗时,也必然还有优化的空间。
另外,本文对 Matlab 和 GPU 上的运行时间进行对比,以衡量加速效果(采用相同数据,相同的迭代次数):
GPU 运行时间为:32.1842 ms
Matlab 运行时间为:11102.919 ms
最终的加速比为:345 倍
下图所示的为聚类结果,按每一类中的样本数从大到小排列,由于初始聚类中心的不同,有部份计算误差。
4. 完整代码
来源:CSDN
作者:木子超同学
链接:https://blog.csdn.net/llcchh012/article/details/47072397