3-2 スレッド・ローカル・ストレージの使用による同期化の軽減

概要

同期の処理はオーバーヘッドを伴うため、マルチスレッド・プログラムのパフォーマンスを妨げる恐れがあります。条件によっては、スレッド間で共有するデータ構造の代わりに、スレッド・ローカル・ストレージを使用することで、同期を軽減し、プログラムの実行速度を向上できます。

この記事は、「マルチスレッド・アプリケーションの開発のためのインテル・ガイド」の一部で、インテル® プラットフォーム向けにマルチスレッド・アプリケーションを効率的に開発するための手法について説明します。

はじめに

複数のスレッドでデータ構造を共有し、そのうちの少なくとも 1 つのスレッドが書き込みを行う場合、共有データの整合性を保つために、スレッド間で同期が必要になることがあります。このような場合、スレッドがロックを取得してから共有データ構造への読み取り/書き込みを行い、終了後にロックを解放することでアクセスを同期する方法が一般的です。

どのようなロック手法でも、ロックしたデータ構造の保持にはオーバーヘッドが伴います。また、アトミック命令を使用するため、現在のプロセッサーでは速度の低下を招きます。同期コード内では並列実行が制限され、シリアル実行によるボトルネックが発生するため、同期はプログラムの実行速度も低下させます。このため、コード内のタイム・クリティカルなセクションで同期を行うと、コードのパフォーマンスが損なわれます。

共有データ構造の代わりに、スレッド・ローカル・ストレージを使用するようにプログラムを変更することで、マルチスレッド化されたタイム・クリティカルなコードセクションから同期を排除することができます。これは、共有データへのリアルタイムのアクセス順序が重要ではない場合のみ可能です。ただし、アクセス順序が重要な場合でも、実行頻度の低い、タイム・クリティカルではないセクションへ順序設定を安全に移動することができれば、同期を排除することができます。

例えば、複数のスレッドで発生するイベントをカウントする変数について考えてみましょう。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)
omp_set_dynamic(0);
#pragma omp parallel 
{
. . .
  if (event_happened) {
    tcount++;
  }
  . . .
}
#pragma omp parallel shared(count)
{
#pragma omp atomic
  count += tcount;
}

ここでは、スレッドごとにプライベート変数 tcount を使用して、それぞれのカウントを格納しています。最初の並列領域ですべてのローカルイベントをカウントし、次の領域でそれらを集計して合計を算出します。このソリューションでは、イベントごとに同期を行う代わりに、スレッドごとに同期を行っています。そのため、スレッド数よりもイベントの発生回数のほうが多ければ多いほど、パフォーマンスが向上します。このプログラムでは、どちらの並列領域のスレッド数も同じであると仮定しています。omp_set_dynamic(0) を呼び出すことで、プログラムで要求された数のスレッドが使用されるようにしています。

タイム・クリティカルなコードセクションでスレッド・ローカル・ストレージを使用するもう 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 );
With Windows threads, the operation is very similar. The programmer allocates a TLS index with TlsAlloc, then uses that index to set a thread-local value. For example:
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 ルーチン内の同期を排除することができます。スレッドごとに個別の tpbuffertpcurrent_ptr を割り当てることで、update_log 内のクリティカル・セクションが排除されます。スレッド・プライベート・バッファーの値は、後で time の値に従って、タイム・クリティカルではないコードセクションで結合することができます。

利用ガイド

この手法を利用する場合は、トレードオフに注意する必要があります。この手法は、同期をタイム・クリティカルなコードセクションからタイム・クリティカルではないコードセクションに移動するものであって、同期の必要性を排除するものではありません。

  • 最初に、同期によって該当コード部分で実際に速度低下が発生しているかどうかを検証します。インテル® Parallel Amplifier やインテル® VTune™ パフォーマンス・アナライザーを使用して、各コードセクションのパフォーマンス問題を確認できます。
  • 次に、プログラムの処理の実行順序が重要であるかどうかを検証します。重要ではない場合は、前述のイベント回数をカウントする例のように、同期を排除できます。重要な場合は、順序設定を後のタイム・クリティカルではないコードセクションで正しく実行できるかどうか検証します。
  • 最後に、同期を別のコードセクションに移動することで、移動先のコードセクションで同様のパフォーマンス問題が生じないことを確認します。確認方法の 1 つとして、前述のイベント回数をカウントする例のように、変更後に同期の回数が大幅に軽減されるかどうか見てみると良いでしょう。