目前,我正在使用OpenMP和C对并行版本的k-means ++进行编程。到目前为止,我正在实现质心的初始化。如果您不熟悉此过程,则其工作方式大致为follows。给定dataset
(矩阵)带n
个点,k
质心使用概率函数'(也称为轮盘选择)来启动。
假设您有n=4
个点以及以下到某些质心的距离数组:
distances = [2, 4, 6, 8]
dist_sum = 20
通过将distances
的每个条目除以dist_sum
并添加之前的结果来定义累积概率数组,如下所示:
probs = [0.1, 0.2, 0.3, 0.4] = [2/20, 4/20, 6/20, 8/20]
acc_probs = [0.1, 0.3, 0.6, 1.0]
然后,执行轮盘选择。给定一个随机数,比如r=0.5
,使用r
和acc_probs
选择下一个点,迭代acc_probs
直到r < acc_probs[i]
。在此示例中,所选点为i=2
,因为r < acc_probs[2]
。
问题
在这种情况下,我使用非常大的矩阵(大约n=16 000 000
个点)。尽管该程序给出了正确答案(即质心的良好初始化),但它的扩展性不如预期。此函数根据此算法计算初始质心。
double **parallel_init_centroids (double **dataset, int n, int d, int k, RngStream randomizer, long int *total_ops) {
double dist=0, error=0, dist_sum=0, r=0, partial_sum=0, mindist=0;
int cn=0, cd=0, ck = 0, cck = 0, idx = 0;
ck = 0;
double probs_sum = 0; // debug
int mink=0, id=0, cp=0;
for (ck = 0; ck < k; ck++) {
if ( ck == 0 ) {
// 1. choose an initial centroid c_0 from dataset randomly
idx = RngStream_RandInt (randomizer, 0, n-1);
}
else {
// 2. choose a successive centroid c_{ck} using roulette selection
r = RngStream_RandU01 (randomizer);
idx = 0;
partial_sum = 0;
for (cn=0; cn<n; cn++) {
partial_sum = partial_sum + distances[cn]/dist_sum;
if (r < partial_sum) {
idx = cn;
break;
}
}
}
// 3. copy centroid from dataset
for (cd=0; cd<d; cd++)
centroids[ck][cd] = dataset[idx][cd];
// reset before parallel region
dist_sum = 0;
// -- parallel region --
# pragma omp parallel shared(distances, clusters, centroids, dataset, chunk, dist_sum_threads, total_ops_threads) private(id, cn, cck, cd, cp, error, dist, mindist, mink)
{
id = omp_get_thread_num();
dist_sum_threads[id] = 0; // each thread reset its entry
// parallel loop
// 4. recompute distances against centroids
# pragma omp for schedule(static,chunk)
for (cn=0; cn<n; cn++) {
mindist = DMAX;
mink = 0;
for (cck=0; cck<=ck; cck++) {
dist = 0;
for (cd=0; cd<d; cd++) {
error = dataset[cn][cd] - centroids[ck][cd];
dist = dist + (error * error); total_ops_threads[id]++;
}
if (dist < mindist) {
mindist = dist;
mink = ck;
}
}
distances[cn] = mindist;
clusters[cn] = mink;
dist_sum_threads[id] += mindist; // each thread contributes before reduction
}
}
// -- parallel region --
// 5. sequential reduction
dist_sum = 0;
for (cp=0; cp<p; cp++)
dist_sum += dist_sum_threads[cp];
}
// stats
*(total_ops) = 0;
for (cp=0; cp<p; cp++)
*(total_ops) += total_ops_threads[cp];
// free it later
return centroids;
}
如您所见,并行区域计算n
d
- 维度点与k
d
- 维度质心之间的距离。这项工作在p
个线程(最多32个)之间共享。并行区域完成后,将填充两个数组:distances
和dist_sum_threads
。第一个数组与前一个示例相同,而第二个数组包含每个线程收集的累积距离。考虑前面的示例,如果p=2
线程可用,则此数组定义如下:
dist_sum_threads[0] = 6 ([2, 4]) # filled by thread 0
dist_sum_threads[1] = 14 ([6, 8]) # filled by thread 1
dist_sum
是通过添加dist_sum_threads
的每个条目来定义的。此函数按预期工作,但是当线程数增加时,执行时间会增加。此figure显示了一些效果指标。
我的实施有什么问题,特别是对于openmp?总之,只使用了两个编译指示:
# pragma omp parallel ...
{
get thread id
# pragma omp for schedule(static,chunk)
{
compute distances ...
}
fill distances and dist_sum_threads[id]
}
换句话说,我删除了障碍,互斥访问以及其他可能导致额外开销的事情。但是,随着线程数量的增加,执行时间最短。
更新
n=100000
点和k=16
质心之间的距离。 omp_get_wtime
测量执行时间。总时间存储在wtime_spent
。dist_sum
的缩减。但是,它没有按预期工作(它被评为下面的不良并行减少)。 dist_sum
的正确值为999857108020.0
,但是,当使用p
个线程计算它时,结果为999857108020.0 * p
,这是错误的。这是主要的并行功能,完整代码位于here:
double **parallel_compute_distances (double **dataset, int n, int d, int k, long int *total_ops) {
double dist=0, error=0, mindist=0;
int cn, cd, ck, mink, id, cp;
// reset before parallel region
dist_sum = 0;
// -- start time --
wtime_start = omp_get_wtime ();
// parallel loop
# pragma omp parallel shared(distances, clusters, centroids, dataset, chunk, dist_sum, dist_sum_threads) private(id, cn, ck, cd, cp, error, dist, mindist, mink)
{
id = omp_get_thread_num();
dist_sum_threads[id] = 0; // reset
// 2. recompute distances against centroids
# pragma omp for schedule(static,chunk)
for (cn=0; cn<n; cn++) {
mindist = DMAX;
mink = 0;
for (ck=0; ck<k; ck++) {
dist = 0;
for (cd=0; cd<d; cd++) {
error = dataset[cn][cd] - centroids[ck][cd];
dist = dist + (error * error); total_ops_threads[id]++;
}
if (dist < mindist) {
mindist = dist;
mink = ck;
}
}
distances[cn] = mindist;
clusters[cn] = mink;
dist_sum_threads[id] += mindist;
}
// bad parallel reduction
//#pragma omp parallel for reduction(+:dist_sum)
//for (cp=0; cp<p; cp++){
// dist_sum += dist_sum_threads[cp];
//}
}
// -- end time --
wtime_end = omp_get_wtime ();
// -- total wall time --
wtime_spent = wtime_end - wtime_start;
// sequential reduction
for (cp=0; cp<p; cp++)
dist_sum += dist_sum_threads[cp];
// stats
*(total_ops) = 0;
for (cp=0; cp<p; cp++)
*(total_ops) += total_ops_threads[cp];
return centroids;
}
答案 0 :(得分:2)
你的代码不是mcve我只能在这里发出假设。但是,这是我认为(可能)发生的事情(没有特定的重要性顺序):
更新dist_sum_threads
和total_ops_threads
时,您会遭受虚假分享。只需声明reduction( +: dist_sum )
并在dist_sum
区域内直接使用parallel
即可完全避免使用前者。您也可以使用本地total_ops_threads
声明total_ops
来reduction(+)
进行同样的操作,并在最后累积到*total_ops
。 (BTW,dist_sum
已计算但从未使用过......)
代码看起来仍然是内存绑定,因为你有大量的内存访问几乎没有计算。因此,预期的加速将主要取决于您的内存带宽和您在代码并行化时可以访问的内存控制器的数量。有关详细信息,请参阅this epic answer。
根据上述问题的内存限制特征,尝试使用内存放置(numactl
可能和/或线程关联proc_bind
)。您还可以尝试使用线程调度策略和/或尝试查看某些循环切片是否无法应用于您的问题以阻止数据进入缓存。
您没有详细说明测量时间的方式,但请注意,加速仅在挂钟时间而非CPU时间的情况下才有意义。请使用omp_get_wtime()
进行任何此类测量。
尝试解决这些问题并根据您的内存架构评估您的实际潜在加速。如果您仍然觉得自己没有达到应有的水平,那么只需更新您的问题。
修改强>:
由于您提供了一个完整的示例,我设法对您的代码进行了一些实验并实现了我想到的修改(主要是为了减少错误共享)。
这是函数no的样子:
double **parallel_compute_distances( double **dataset, int n, int d,
int k, long int *total_ops ) {
// reset before parallel region
dist_sum = 0;
// -- start time --
wtime_start = omp_get_wtime ();
long int tot_ops = 0;
// parallel loop
# pragma omp parallel for reduction( +: dist_sum, tot_ops )
for ( int cn = 0; cn < n; cn++ ) {
double mindist = DMAX;
int mink = 0;
for ( int ck = 0; ck < k; ck++ ) {
double dist = 0;
for ( int cd = 0; cd < d; cd++ ) {
double error = dataset[cn][cd] - centroids[ck][cd];
dist += error * error;
tot_ops++;
}
if ( dist < mindist ) {
mindist = dist;
mink = ck;
}
}
distances[cn] = mindist;
clusters[cn] = mink;
dist_sum += mindist;
}
// -- end time --
wtime_end = omp_get_wtime ();
// -- total wall time --
wtime_spent = wtime_end - wtime_start;
// stats
*(total_ops) = tot_ops;
return centroids;
}
所以,一些评论:
如前所述,dist_sum
和操作总数(tot_ops
)的局部变量现在声明为reduction(+:)
。这避免了每个索引使用一个线程访问相同的数组,这会触发false sharing(这几乎是触发它的最佳情况)。我使用局部变量而不是total_ops
作为后者作为指针,它不能直接在reduction
子句中使用。但是,使用tot_ops
在最后更新它可以完成工作。
我尽可能地延迟了所有变量声明。这是一种很好的做法,因为它省去了大多数private
声明,这通常是OpenMP程序员的主要陷阱。现在你只需要考虑两个reduction
变量和两个数组,它们显然是shared
,因此不需要任何额外的声明。这简化了parallel
指令,并有助于关注重要事项
既然不再需要线程ID,可以合并parallel
和for
指令以提高可读性(也可能是性能)。
我删除了schedule
子句,让编译器和/或运行时库使用默认值。如果我有充分的理由,我只会采用不同的日程安排政策。
有了这个,在我的双核笔记本电脑上唱GCC 5.3.0并使用-std=c99 -O3 -fopenmp -mtune=native -march=native
进行编译,我得到了各种线程的一致结果,以及2个线程的2倍速度。
在使用英特尔编译器和-std=c99 -O3 -xhost -qopenmp
的10核机器上,我从1到10个线程获得线性加速......
即使在Xeon Phi KNC上,我也可以从1到60个线程获得接近线性的加速(然后使用更多的硬件线程仍会提供一些加速,但不会达到同样的速度)。
观察到的加速使我意识到,与我假设的不同,代码不受内存限制,因为您访问的数组实际上已经很好地缓存了。这样做的原因是您只能访问第二维非常小(40和16)的dataset[cn][cd]
和centroids[ck][cd]
,因此非常适合缓存,而dataset
的块可以加载到下一个cn
索引可以有效预取。
您是否仍然遇到此版本代码的可伸缩性问题?