この記事は、インテル® ソフトウェア・ネットワークに掲載されている「Use Thread-local Storage to Reduce Synchronization」 (http://software.intel.com/en-us/articles/use-thread-local-storage-to-reduce-synchronization/) の日本語参考訳です。
編集注記:
本記事は、2011 年 5 月 4 日に公開されたものを、加筆・修正したものです。
はじめに
同期処理はオーバーヘッドを伴うため、マルチスレッド・プログラムのパフォーマンスを妨げる恐れがあります。条件によっては、スレッド間で共有するデータ構造の代わりに、スレッド・ローカル・ストレージ (TLS) を使用することで、同期を軽減し、プログラムの実行速度を向上できます。
この記事は、「マルチスレッド・アプリケーションの開発のためのガイド」の一部で、インテル® プラットフォーム向けにマルチスレッド・アプリケーションを効率的に開発するための手法について説明します。
背景
複数のスレッドでデータ構造を共有し、そのうちの少なくとも 1 つのスレッドが書き込みを行う場合、共有データの整合性を保つためスレッド間で同期が必要になることがあります。この場合、スレッドがロックを取得してから共有データ構造への読み取り/書き込みを行い、終了後にロックを解放することで処理を同期する方法が一般的です。
どのようなロック手法でも、ロックしたデータ構造を保護するにはオーバーヘッドが伴います。また、アトミック命令を使用するため、現在のプロセッサーでは速度の低下を招きます。同期コード内では並列実行が制限され、シリアル実行によるボトルネックが発生するため、同期はプログラムの実行速度も低下させます。そのため、コード内のタイム・クリティカルなセクションで同期を行うと、コードのパフォーマンスは大幅に損なわれます。
共有データ構造の代わりに、スレッド・ローカル・ストレージ (TLS) を使用することで、マルチスレッド化されたタイム・クリティカルなコードセクションから同期を排除することができます。これは、共有データへのリアルタイムのアクセス順序が重要ではない場合のみ可能です。ただし、アクセス順序が重要な場合でも、実行頻度の低い、タイム・クリティカルではないセクションへ順序設定を安全に移動することができれば、同期を排除することができます。
例えば、複数のスレッドで発生するイベントをカウントする変数について考えてみましょう。OpenMP* を使用して、以下のようなプログラムを作成することができます。
int count=0; #pragma omp parallel shared(count) { . . . if (event_happened) { #pragma omp atomic count++; } . . . }
このプログラムでは、イベントを検知するたびに、count へのアクセスが 1 つのスレッドだけであることを保証するため同期を行う必要があり、すべてのイベントで同期が行われます。この同期を排除することで、プログラムの実行速度を向上できます。一例として、以下のように、並列領域内で各スレッドにイベントの発生回数をカウントさせ、後でそれぞれのカウントを集計します。
int count=0; int tcount=0; #pragma omp threadprivate(tcount) int func() #pragma omp parallel { . . . if (event_happened) { tcount++; } . . . } #pragma omp parallel shared(count) { #pragma omp atomic count += tcount; } }
ここでは、スレッドごとにプライベート変数 tcount を使用して、それぞれのカウントを格納しています。最初の並列領域ですべてのローカルイベントをカウントし、次の領域でそれらを集計して合計を算出します。この方法では、イベントごとに同期を行う代わりに、スレッドごとに同期を行っています。そのため、スレッド数よりもイベントの発生回数が多いほど、パフォーマンスが向上します。このプログラムでは、どちらの並列領域のスレッド数も同じであると仮定しています。
タイム・クリティカルなコードセクションでスレッド・ローカル・ストレージを使用するもう 1 つのメリットは、プロセッサー・キャッシュが共有されていない場合、共有データよりもプロセッサー・キャッシュにデータが長く保持されることです。複数のプロセッサーのデータキャッシュに同じアドレスが存在する場合、1 つのプロセッサー・コアがそのアドレスに書き込みを行うと、それ以外のすべてのプロセッサー・コアのキャッシュでそのアドレスが無効となり、メモリーから再度読み込む必要があります。その点、スレッド・ローカル・データは、1 つの (ローカルの) プロセッサー・コアによってのみ書き込みが行われ、別のプロセッサー・コアによって書き込まれることがないため、プロセッサーのキャッシュに長くとどまりやすくなります。
上記のサンプルコードは、OpenMP* でのスレッド・ローカル・データの使用方法を示した一例です。Pthreads で同様の効果を得るには、スレッド・ローカル・データ用のキーを作成し、そのキーを使用してデータにアクセスする必要があります。次に例を示します。
#include pthread_key_t tsd_key; value; if( pthread_key_create(&tsd_key, NULL) ) err_abort(status, "Error creating key"); if( pthread_setspecific( tsd_key, value)) err_abort(status, "Error in pthread_setspecific"); . . . value = ()pthread_getspecific( tsd_key );
Windows* のスレッドでも同様です。TLS インデックスを TlsAlloc で割り当て、そのインデックスを使用して、スレッドローカルな値を設定します。次に例を示します。
DWORD tls_index; LPVOID value; tls_index = TlsAlloc(); if (tls_index == TLS_OUT_OF_INDEXES) err_abort( tls_index, "Error in TlsAlloc"); status = TlsSetValue( tls_index, value ); if ( status == 0 ) err_abort( status, "Error in TlsSetValue"); . . . value = TlsGetValue( tls_index );
OpenMP* では、parallel プラグマで private 節で指定することで、スレッドローカル変数を作成することもできます。これらの変数は、並列領域の最後に自動で割り当てが解除されます。また別の方法として、スレッド化モデルに関係なく、与えられたスコープ内のスタックで割り当てられた変数を使用して、スレッド・ローカル・データを指定することも可能です。この場合、スコープの最後で変数の割り当てが解除されます。
アドバイス
スレッド・ローカル・ストレージを使用する手法は、タイム・クリティカルなコードセクションで同期が行われ、同期される処理がリアルタイムの順序設定を必要としない場合に適しています。ただし、処理の実行順序が重要な場合でも、タイム・クリティカルなセクションで十分な情報を収集でき、後でタイム・クリティカルではないコードセクションで順序設定を再現できる場合は、この手法を使用することができます。
例えば、スレッドが共有バッファーへ書き込みを行う次の例について考えてみましょう。
int buffer[NENTRIES]; main() { . . . #pragma omp parallel { . . . update_log(time, value1, value2); . . . } . . . } void update_log(time, value1, value2) { #pragma omp critical { if (current_ptr + 3 > NENTRIES) { print_buffer_overflow_message(); } buffer[current_ptr] = time; buffer[current_ptr+1] = value1; buffer[current_ptr+2] = value2; current_ptr += 3; } }
time は値が一定に増加する変数であると仮定します。このプログラムは、time でソートされたバッファーデータをファイルに書き込みます。ここでは、スレッド・ローカル・バッファーを使用して、update_log ルーチン内の同期を排除することができます。スレッドごとに個別の tpbuffer と tpcurrent_ptr を割り当てることで、 update_log 内のクリティカル・セクションが排除されます。スレッド・プライベート・バッファーの値は、後で time の値に従って、タイム・クリティカルではないコードセクションで結合することができます。
利用ガイド
この手法を利用する場合は、トレードオフに注意する必要があります。この手法は、同期をタイム・クリティカルなコードセクションからタイム・クリティカルではないコードセクションに移動するものであって、同期を排除するものではありません。以下の手順でスレッド・ローカル・ストレージの実装を検討してください。
- 最初に、同期によって該当コード部分で実際に速度低下が発生しているかどうかを検証します。インテル® VTune™ Amplifier を使用して、各コードセクションのパフォーマンス問題を確認できます。
- 次に、プログラムの処理の実行順序が重要であるかどうかを検証します。重要ではない場合は、前述のイベント回数をカウントする例のように、同期を排除できます。重要な場合は、順序設定を後のタイム・クリティカルではないコードセクションで正しく実行できるかどうか検証します。
- 最後に、同期を別のコードセクションに移動することで、移動先のコードセクションで同様のパフォーマンス問題が生じないことを確認します。確認方法の 1 つとして、前述のイベント回数をカウントする例のように、変更後に同期の回数が大幅に軽減されるかどうか見てみると良いでしょう。