第11章 k-平均法クラスタリング
11.1 アルゴリズム
k-平均法は、データセットをk個のクラスターに分類する代表的な反復型アルゴリズムである。各クラスターの「中心」を基準に、以下のプロセスを繰り返すことで、最適な分類を行う。
2つの主要ステップと並列化パターン
- マップパターン
- 各データポイントに対し、最も距離が近い中心点を持つクラスターを特定し、そのクラスターに所属を更新する。
- 並列化の特性: 各データポイントの距離計算は他と完全に独立しているため、一斉に手分けして処理を行う「マップパターン」を適用できる。
- リダクションパターン
- 各クラスターに属する全データポイントの座標の平均値を計算し、新しい中心点とする。
- 並列化の特性: 全データの情報を足し合わせて「集計・縮約」する必要があるため、複数のスレッドがバラバラに計算した結果を安全に合体させる「リダクションパターン」の手法が求められる。
フュージョンで処理を1つにまとめる
教科書通りの順番で作ると、「① 全データのグループ分け」がすべて終わった後に、もう一度最初からデータを読み直して「② 新しい中心の計算」をすることになる。これでは、重いデータを2回も読み込むことになり、オーバーヘッドが発生してしまう。
そこで、計算を限界まで速くするために、実際の並列化プログラムでは「グループ分け」と「リダクション」を1つのループ(do-while文)の中にフュージョンさせて実装する。こうすることで、データを読み込む回数を1回に減らし、圧倒的なスピードアップを実現できる。
1 2 3 4 5 6 7 | |
11.2 k-平均法とCilk Plus
データ構造:point に関する最小限のルール(3つの仮定)
プログラムを並列で動かして、なおかつ計算を正しく・高速に行うためには、扱うデータ(point)が以下の3つの性質を必ず満たしている必要がある。
point()で「空」の状態を作れること- 集計を始めるときに、最初に数値をリセットした「空の箱」を用意できる必要がある。
q += pで足し算加算が正しくできること- ポイント同士を安全に足し合わせて、合計座標を蓄積できる必要がある。
p / nで平均値が出せること- 合計した座標をデータの個数nで割って、新しい「平均の中心座標」を計算できる必要がある。
リスト 11.1:Cilk Plus による k-平均法の実装
Cilk Plusの機能を使って、k-平均法の全体制御と並列化を行うメイン処理のコード。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
このコードの役割
- 大量のデータポイントを、もっとも近い中心点(クラスター)にグループ分けし、全データの位置が変わらなくなるまで中心点を更新し続ける処理。
リスト 11.2:sum_and_count 構造体によるデータ定義
各クラスターの新しい中心(平均値)を計算するために、データを一時的に溜めておく「集計箱」となる構造体の定義コード。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
このコードの役割
* 各クラスターに属するデータの「座標の合計」と「データの個数」を常にセットで記録・更新するための仕組み。
11.2.1 ハイパーオブジェクト
複数のスレッドが同時に同じクラスターの合計値を更新しようとすると、計算結果がぐちゃぐちゃに破壊されるデータ競合が発生する。Cilk Plusではこれを防ぐため、リダクターを使用する。
リダクターの宣言と並列ループ
1 2 3 4 5 6 7 8 9 10 | |
11.2.1 のポイント
cilk::reducer_opadd<sum_and_count> sum[k];- 何をしているか
- クラスターの数kの分だけ、並列処理に対応した特別な「リダクター」の配列を作っている。
- なぜ必要なのか
- 普通の配列だと、複数のスレッドが同じ場所に同時にデータを書き込んだときに、値が上書きされて消えてしまう「データ競合」が発生する。
- このリダクターを使うことで、Cilk Plusがスレッドごとに独立したビューをメモリ上に自動で用意してくれる。その結果、スレッド同士がお互いに邪魔をすることなく、一斉に計算を進めることができる。
- 何をしているか
11.3 Intel TBB による実装と最適化
Intel TBB(Threading Building Blocks)を用いた実装では、コンパイラ拡張機能(cilk_for など)を使わずに、ライブラリのテンプレートクラスや構造体を利用して並列化を行う。そのため、メモリ管理や最終的なデータの集計プロセスを明示的に記述する必要がある。
スレッド間の干渉防止(TLSの定義)
リスト 11.3:Intel TBB による実装(TLSの定義)
データ競合を回避するために tbb::enumerable_thread_specific を用いて、各スレッド専用のワークスペースであるスレッド・ローカル・ストレージ(TLS)を構造体内部に定義する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
このコードの役割
- 何をしているか
- Intel TBBで並列ループ処理(
parallel_for)を実行するために必要なデータや、スレッドごとの作業領域を一つにまとめた「タスク管理用の構造体」を定義している。 - 6行目で、TBB特有の安全な集計領域である
local_sums(TLS)をメンバー変数として持たせている。
- Intel TBBで並列ループ処理(
リスト11.4 Intel TBB におけるスレッド・ローカル・ビューの宣言
Intel TBBでk-平均法を動かす際、Cilk Plusのハイパーオブジェクトのような仕組みをライブラリの機能で構築するため、各スレッドが個別に持つ「ビュー」を明示的に定義する。
リスト 11.4:TBBにおけるスレッド・ローカル・ビューのtls_type型の宣言
データ競合を完全に回避するため、各スレッドが持つべき「集計用配列(sum_and_count)」と「移動検知用フラグ(change)」を1つにまとめた view クラス、およびそのTLS型の定義コード。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
このコードの役割
-
何をしているか
- 各スレッドが並列ループの中で他人に邪魔されずに計算結果を書き込めるように、スレッド専用の「記録メモ(
array)」と「移動カウンター(change)」をセットにしたviewクラスを定義している。
- 各スレッドが並列ループの中で他人に邪魔されずに計算結果を書き込めるように、スレッド専用の「記録メモ(
-
なぜこの構造が必要か
- Cilk Plusでは、移動フラグの集計もリダクター(
cilk::reducer_opadd<size_t> change)が勝手にやってくれましたが、TBBにはそれがない。 - そのため、「座標の合計(
sum_and_count)」だけでなく、「そのスレッド内でデータが何回動いたか(change)」も、スレッドごとに完全に隔離した状態で一緒にメモして持ち回る必要があるため、このような構造が必要になる。
- Cilk Plusでは、移動フラグの集計もリダクター(
リスト 11.5:ローカルビューの変更の検出
各スレッドが個別にカウントしていた「データポイントの移動回数(change)」をすべて足し合わせて、全体の移動合計を算出する関数。
1 2 3 4 5 6 7 8 9 10 11 12 | |
リスト11.5の役割
-
何をしているか
- 各スレッドが自分の担当タイル内で検知した「ポイントが動いた回数(
v.change)」をループで全て回収し、全体のメモ(global.change)に足し算している。 - 回収し終わったスレッドの手元カウンターは、次の周回のために
0にリセットしている。
- 各スレッドが自分の担当タイル内で検知した「ポイントが動いた回数(
-
なぜこの構造が必要か
- Cilk Plusのときは、ポイントが1つでも動いたかどうかの判定(
changeフラグの集計)をシステムが自動で処理してくれましたが、TBBではスレッドごとにバラバラのメモとして残ったままになる。 - そのため、まだ動いているポイントがあるかを正確に判定するために、このように明示的なループを回して手動で集計する必要がある。
- Cilk Plusのときは、ポイントが1つでも動いたかどうかの判定(
リスト 11.6:ローカルビューからグローバル合計への累積(座標と個数の集計)
各スレッドの手元メモに溜まった「座標の合計(sum)」と「ポイントの個数(count)」を、グローバル配列へ安全に合体させる関数。
1 2 3 4 5 6 7 8 9 10 11 12 | |
リスト11.6の役割
-
何をしているか
- 各スレッドが別々に計算したクラスターごとの「座標の足し算結果」と「カウントした個数」を、2重ループを使って全体の共通配列(
global.array)にすべて集約している。 - 集計が終わったスレッドの配列は、次の周回のために
clear()で綺麗にリセットしている。
- 各スレッドが別々に計算したクラスターごとの「座標の足し算結果」と「カウントした個数」を、2重ループを使って全体の共通配列(
-
なぜこの構造が必要か
- 並列処理中は、スレッド同士の衝突を防ぐために、わざとデータをバラバラのTLSに保存させていた。
- ループが終わった後は、それらを1つにまとめないと新しい中心座標が計算できないため、構造を作成する。
リスト 11.7:最も近い中心のインデックスを検出するルーチン
指定されたデータポイントから最も距離が近い中心点(クラスター)のIDを、愚直なループ(シリアル処理)で探索して返す補助関数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
リスト11.7の役割
-
何をしているか
- 1つのデータポイントに注目し、すべてのクラスター中心(
centroid)との距離を順番に計算して、「自分が今、どのグループに一番近いか」のインデックスを特定して返している。
- 1つのデータポイントに注目し、すべてのクラスター中心(
-
なぜこの構造が必要か
- Cilk Plusのときは、コンパイラ専用の特殊な並列リダクション表現(
__sec_reduce_min_ind)を使って1行で書けましたが、Intel TBBにはそのような専用のコンパイラ構文がない。 - そのため、通常のC++コードとして同じ動きをするシリアル(逐次)ループを明示的に自作して、TBBの並列ループ内から呼び出す必要がある。
- Cilk Plusのときは、コンパイラ専用の特殊な並列リダクション表現(
リスト 11.8:TBB による k-平均法クラスタリング
これまで定義してきたスレッドローカル領域(tls)やマージ関数(リスト11.5, 11.6)をすべて組み合わせた、TBB版k-平均法のメイン処理コード。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | |
リスト11.8の役割
-
何をしているか
- これまで個別に作ってきた「スレッド専用メモの準備(
tls)」「最短距離の探索(reduce_min_ind)」「データの回収(reduce_local_...)」を1つの流れに繋ぎ合わせ、データが全く動かなくなるまで(収束するまで)ループを回してクラスタリングを完成させている。
- これまで個別に作ってきた「スレッド専用メモの準備(
-
なぜこの構造が必要か
- TBBではコンパイラが自動で裏側の同期をやってくれないため、処理のタイムラインを明示的に制御する必要がある。
- 「並列で手分けして計算する(
parallel_for)」 $\rightarrow$ 「ループが終わったら一度みんなのメモを一箇所に集める(reduce)」 $\rightarrow$ 「集まったデータを元に中心点を更新する(centroid[j] = ...)」という、並列処理の正しいフェーズ管理を破綻なく実行させるために、この統括関数が必要になる
11.4 まとめ
k-平均法クラスタリングを並列化するにあたり、Cilk PlusとIntel TBBのどちらを使うかで、コードの書き方や裏側の仕組みが大きく変わる。
Cilk Plus と Intel TBB の決定的な違い
-
Cilk Plusは「すべて自動おまかせ」
- コンパイラの特別な機能を使っているため、コードが非常にシンプル。
- リダクターを宣言しておけば、並列ループが終わった瞬間に、各スレッドがバラバラにメモしていた結果をシステムが裏側で自動的に合体してくれる。
-
Intel TBBは「手動で明示的に書く」
- TBBは純粋なC++のライブラリなので、裏側で勝手にやってくれない。
- スレッドごとにTLSを自作し、並列ループが終わった後には、それらのメモを1つずつ手作業で回収して足し合わせるコードを自分で書く必要がない。