[英]OpenMP program (k-means++) does not scale
目前,我正在使用OpenMP和C編寫並行版本的k-means ++。到目前為止,我正在實現質心的初始化。 如果您不熟悉此過程,則大致如下 。 給定具有n
個點的dataset
(矩陣),使用“概率函數”(也稱為輪盤選擇)來啟動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
每個條目來dist_sum_threads
。 此函數按預期工作,但是當線程數增加時,執行時間會增加。 該圖顯示了一些績效指標。
我的實現有什么問題,特別是對於openmp? 總之,只使用了兩個pragma:
# 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
,這是錯誤的。 這是主要的並行功能,完整的代碼位於這里 :
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; }
你的代碼不是mcve我只能在這里發出假設。 但是,這是我認為(可能)發生的事情(沒有特定的重要性順序):
更新dist_sum_threads
和total_ops_threads
時,您會遭受錯誤共享。 您可以通過簡單地聲明reduction( +: dist_sum )
並在parallel
區域內直接使用dist_sum
來完全避免前者。 您也可以使用本地total_ops
聲明的reduction(+)
對total_ops_threads
執行total_ops_threads
操作,並在最后累積到*total_ops
中。 (BTW, dist_sum
計算但從未使用過......)
代碼看起來仍然是內存綁定,因為你有大量的內存訪問幾乎沒有計算。 因此,預期的加速將主要取決於您的內存帶寬和您在代碼並行化時可以訪問的內存控制器的數量。 有關詳細信息,請參閱此史詩答案 。
鑒於上述可能存在內存的問題,嘗試使用內存放置(可能是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(+:)
。 這樣可以避免每個索引使用一個線程訪問同一個數組,從而觸發錯誤共享 (這幾乎是觸發它的最佳情況)。 我使用局部變量而不是total_ops
作為后者是指針,它不能直接在reduction
子句中使用。 但是,最后使用tot_ops
更新它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個線程獲得接近線性的加速(然后使用更多的硬件線程仍會提供一些加速,但不會達到相同的速度)。
觀察到的加速使我意識到,與我假設的不同,代碼不受內存限制,因為您訪問的數組實際上已經很好地緩存了。 原因是你只訪問dataset[cn][cd]
和centroids[ck][cd]
,其中第二維非常小(40和16),因此非常適合緩存,而下一個dataset
要加載cn
索引可以有效預取。
您是否仍然遇到此版本代碼的可伸縮性問題?
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.