|
事業仕分けによる次世代スーパーコンピューターの開発予算削減について、どうお考えですか?
|
Intel TBBの並列コンテナによる安全でスケーラブルな並列処理はじめにマルチスレッドアプリケーションは、コーディングもテストもデバッグも難しいことで知られています。しかし、マルチコアのデスクトップシステムとラップトップシステムに秘められた高いパフォーマンスを最大限に利用するために、開発者たちはアプリケーションをスレッド化するという難題に取り組んでいます。マルチスレッドアプリケーション開発の困難な問題に効く万能薬はありませんが、既存のライブラリとツールを活用すれば、この過渡期の負担を大幅に軽減することができます。 本稿では、C++アプリケーションをスレッド化するときに遭遇する正確さとパフォーマンスの問題の主な原因の1つ、すなわちスレッドセーフでないコンテナクラスの使用について考察することにします。まず、この問題がなぜ起きるのかを例で示し、それからIntel Threading Building Blocks(Intel TBB)ライブラリの並列コンテナクラスについて説明します。このライブラリはマルチスレッドアプリケーションの開発を支援すべく設計されたC++テンプレートライブラリです。TBBの並列コンテナクラスを利用すると、アプリケーションにスケーラブルな並列処理を安全に追加することができます。 あなたのコンテナはスレッドセーフか?多くの開発者は、C++の標準テンプレートライブラリ(STL)の実装に含まれているコンテナクラスか、自作のコンテナクラスに頼っています。しかし困ったことに、こうしたライブラリはスレッドセーフでないことが多いのです。STLの仕様では、スレッドやマルチスレッドコードで使用する際のコンテナクラスの必須動作やスレッドについて何も言及していません。そのため、これらのSTLコンテナクラスの実装がスレッドセーフでないという事態が一般化しているのです。 例えば、STLの DevX編集部注
この記事の著者Michael Vossは、TBBテクノロジのオーナー&デベロッパであるIntel Corporationの上級スタッフソフトウェアエンジニアです。この記事を掲載したのは、この記事に確かな技術的メリットがあると考えたからであり、DevX編集部がIntelのテクノロジを特に支持または推奨しているということではありません。
たとえ異なる2つのキーに関連付けられた異なる2つの値を上記のコードで修正するとしても、大部分のSTL実装は正しい動作を保証しません。これらの操作を同期なしに並列的に実行すると、マップが破損する可能性があります。スレッドセーフに対する要件の指定がないと、異なる2つのマップにアクセスすることでデータ破損を招く可能性さえあります。 もちろん、上記のコードをスレッドセーフにするようなやり方で、STLテンプレートクラスのmapを実装することは可能です。残念ながら、よく使われるmap操作シーケンスの中には、スレッドセーフなやり方で実装できないものがあります。それぞれの操作を単独で実行すればスレッドセーフになるかもしれませんが、シリアルコードの中でよく使われるシーケンスは予期せぬ結果を引き起こすことがあります。例えば、2つのスレッドが次のコードでマップ内の同じ要素を操作したらどうなるでしょう。 Thread 0によって実行されるコードは2つの操作を実行します。まず 望ましい結果は、 この例のような競合の難しい点の1つは、動作が予測不能(非決定論的)だということです。このコードをテストで実行しても、Thread 1からの スレッドフレンドリでないコンテナクラスを使用する際に、こうしたバグを避け、正確さを確保するため、開発者たちは各コンテナの使用時に必ずロックをかけ、一度に1つのスレッドしかアクセスできないようにしています。このような粒度の粗い同期アプローチでは、アプリケーションで使用できる並列処理が制限され、またアクセスポイントごとにコードを追加することになるため、複雑さが増します。しかし、こうした既存のライブラリを利用する以上、これは支払わなければならない代価です。 代替策:Intel TBBのコンテナクラススレッドセーフでないコンテナを粒度の粗い同期でラップする以外にも、別の方法があります。Intel TBBライブラリはスレッドを使用するC++用のランタイムベース並列プログラミングモデルです。ライブラリが提供するスケーラブルな並列アルゴリズムに加え、Intel TBBはマップ、キュー、ベクタのコンテナクラスの安全でスケーラブルな実装を提供します。これらのテンプレートは、ユーザースレッド化コードで直接使用することも、ライブラリに含まれている並列アルゴリズムと共に使用することもできます。 上述したように、一部のコンテナクラスの標準インターフェイスは本質的にスレッドフレンドリではありません。そのため、Intel TBBの並列コンテナは対応するSTLコンテナの単純な代替品にはなりません。そこで、Intel TBBはSTLの精神に従いながらも、スレッドセーフを保証する必要があるところでは修正されたインターフェイスを提供するのです。 Intel TBBライブラリのすべての並列コンテナは「粒度の細かいロック」を使って実装されています。コンテナに対してメソッドが呼び出されたときは、データ構造の中で、そのメソッドが扱う部分だけがロックされるので、他の部分には複数のスレッドが同時にアクセスできます。 以下では、 クラス:concurrent_hash_map< Key, T, HashCompare > Intel TBBのテンプレートクラス 文字列型のキーに対する特性クラスの例を以下に示します。 struct my_hash_compare { static size_t hash( const string& x ) { size_t h = 0; for( const char* s = x.c_str(); *s; ++s ) h = (h*17)^*s; return h; } //! True if strings are equal static bool equal( const string& x, const string& y ) { return x==y; }; 次のコードは、 concurrent_hash_map<string, MyClass, my_hash_compare> string_table; void insert_into_string_table ( string &key, MyClass &m ) { // create an accessor that will act as a smart pointer // for write access concurrent_hash_map<string, MyClass, my_hash_compare>::accessor a; // call insert to create a new element, or return an existing // element if one exists. // accessor a locks this element for exclusive use by this thread string_table.insert( a, key ); // modify the value held by the pair a->second = m; // the accessor "a" releases the lock on the element when it is // destroyed at the end of the scope } concurrent_queue< Key, T, HashCompare > Intel TBBのテンプレートクラス 一般にキューは先入れ先出し方式のデータ構造であり、シングルスレッドプログラムでは、この厳格な順序付けをサポートするようにキューを設計できます。しかし、同時に複数のスレッドがプッシュ/ポップを行う場合、何をもって「先」とするかはあまり明確でなくなります。Intel TBBのテンプレートクラス 並列キューはプロデューサ-コンシューマアプリケーションでよく使われます。このようなアプリケーションでは、あるスレッドで生成されるデータが別のスレッドで消費されます。この種のアプリケーションを柔軟にサポートするため、Intel TBBはポップのブロッキングバージョンと非ブロッキングバージョンを提供しています。 次の例では、 STLの大部分のコンテナと違って、 concurrent_vector< Key, T, HashCompare > TBBで定義されている最後の並列コンテナはテンプレートクラス 次のルーチンは、共有ベクタにC文字列を安全に追加します。 void Append ( concurrent_vector<char>& vector, const char* string) { size_t n = strlen(string) + 1; memcpy( &vector[vector_grow_by(n)], string, n+1 ); } 安全でないコンテナの使用を検知して置き換える方法既に述べたように、TBBのコンテナを使用すると、STLなどの他のライブラリを使ったのでは安全に実現できない(または粒度の粗い同期でラップしなければ安全が保証されない)ような並列処理が可能になります。とはいえ、これらの並列コンテナには、スレッドセーフでないコンテナよりもオーバーヘッドが大きいという難点があります。そのため、これらのスレッドセーフなコンテナを使用するのは、スレッドセーフを保証する必要がある場合か、同時並列性を高める必要がある場合に限るべきです。 従って、「スレッドセーフでないか同時並列性の低いコンテナクラスの使用をどうやって検知するか」というのが最初に検討すべき課題となります。以降では、文字列カウントの例を使って、Intel TBBと共にIntel Threading Tools、Intel Thread Checker、Intel Thread Profiler(http://www.intel.com/software/products/threadingを参照)をどのように使用すればこうした厄介なコンテナクラスを検知して置き換えることができるかを示します。 文字列カウントの例 次のコードでは、STLの 00: const size_t N = 1000000; 01: static string Data[N]; 02: typedef map<string,int> StringTable; 03: StringTable table; 04: DWORD WINAPI tally ( LPVOID arg ) { 05: pair<int, int> *range = 06: reinterpret_cast< pair<int,int> * >(arg); 07: for( int i=range->first; i < range->second; ++i ) 08: table[Data[i]] += 1; 09: return 0; 10: } 11: static void CountOccurrences() { 12: HANDLE worker[8]; 13: pair<int,int> range[8]; 14: int g = static_cast<int>(ceil(static_cast<double>(N) / NThread)); 15: tick_count t0 = tick_count::now(); 16: for (int i = 0; i < NThread; i++) { 17: int start = i * g; 18: range[i].first = start; 29: range[i].second = (start + g) < N ? start + g : N; 20: worker[i] = 21: CreateThread( NULL, 0, tally, &range[i], 0, NULL ); 22: } 23: WaitForMultipleObjects ( NThread, worker, TRUE, INFINITE ); 24: tick_count t1 = tick_count::now(); 25: int n = 0; 26: for( StringTable::iterator i=table.begin(); 27: i!=table.end(); ++i ) 28: n+=i->second; 39: 30: printf("total=%d time = %g ",n,(t1-t0).seconds()); 31: } リスト1
/*================================================ Copyright 2005-2006 Intel Corporation. All Rights Reserved. The source code contained or described herein and all documents related to the source code ("Material") are owned by Intel Corporation or its suppliers or licensors. Title to the Material remains with Intel Corporation or its suppliers and licensors. The Material is protected by worldwide copyright laws and treaty provisions. No part of the Material may be used, copied, reproduced, modified, published, uploaded, posted, transmitted, distributed, or disclosed in any way without Intel’s prior express written permission. No license under any patent, copyright, trade secret or other intellectual property right is granted to or conferred upon you by disclosure or delivery of the Materials, either expressly, by implication, inducement, estoppel or otherwise. Any license under such intellectual property rights must be express and approved by Intel in writing. =================================================*/ #include "tbb/concurrent_hash_map.h" #include "tbb/scalable_allocator.h" #include "tbb/tick_count.h" #include <windows.h> #include <cmath.h> #include <string> #include <utility> #include <cstdio> #if !defined(USE_TBB) #include <map> #endif using namespace tbb; using namespace std; //! Set to true to counts. static bool Verbose = false; static int NThread = 1; #if defined(USE_FAST_STRINGS) typedef basic_string<char, char_traits<char>, scalable_allocator<char> > FastString; #else typedef string FastString; #endif const size_t N = 1000000; static FastString Data[N]; #if defined(USE_TBB) //! Structure that defines hashing and // comparison operations for user’s type. struct MyHashCompare { static size_t hash( const FastString& x ) { size_t h = 0; for( const char* s = x.c_str(); *s; s++ ) h = (h*17)^*s; return h; } //! True if strings are equal static bool equal( const FastString& x, const FastString& y ) { return x==y; } }; typedef concurrent_hash_map<FastString,int,MyHashCompare> StringTable; #else typedef map<FastString,int> StringTable; #endif StringTable table; #if defined(USE_MUTEX) HANDLE table_mutex; #endif #if defined(USE_CS) CRITICAL_SECTION table_cs; #endif DWORD WINAPI tally ( LPVOID arg ) { pair<int, int> *range = reinterpret_cast< pair<int,int> * >(arg); for( int i=range->first; i < range->second; ++i ) { #if defined(USE_TBB) StringTable::accessor a; table.insert( a, Data[i] ); a->second += 1; #else #if defined(USE_MUTEX) WaitForSingleObject ( table_mutex, INFINITE ); #endif #if defined(USE_CS) EnterCriticalSection(&table_cs); #endif table[Data[i]] += 1; #if defined(USE_MUTEX) ReleaseMutex ( table_mutex ); #endif #if defined(USE_CS) LeaveCriticalSection(&table_cs); #endif #endif } return 0; } static void CountOccurrences() { HANDLE worker[8]; pair<int,int> range[8]; int g = static_cast<int>(ceil(static_cast<double>(N) / NThread)); int r = N%NThread; #if defined(USE_MUTEX) table_mutex = CreateMutex( NULL, false, NULL ); #endif #if defined(USE_CS) InitializeCriticalSection(&table_cs); #endif tick_count t0 = tick_count::now(); for (int i = 0; i < NThread; i++) { int start = i * g; range[i].first = start; range[i].second = (start + g) < N ? start + g : N; worker[i] = CreateThread( NULL, 0, tally, &range[i], 0, NULL ); } WaitForMultipleObjects ( NThread, worker, TRUE, INFINITE ); tick_count t1 = tick_count::now(); int n = 0; for( StringTable::iterator i=table.begin(); i!=table.end(); ++i ) { if( Verbose ) printf("%s %d ",i->first.c_str(),i->second); n+=i->second; } printf("total=%d time = %g ",n,(t1-t0).seconds()); #if defined(USE_CS) DeleteCriticalSection(&table_cs); #endif } static const FastString Adjective[] = { "sour", "sweet", "bitter", "salty", "big", "small" }; static const FastString Noun[] = { "apple", "banana", "cherry", "date", "eggplant", "fig", "grape", "honeydew", "icao", "jujube" }; static void CreateData() { size_t n_adjective = sizeof(Adjective)/sizeof(Adjective[0]); size_t n_noun = sizeof(Noun)/sizeof(Noun[0]); for( int i=0; i<N; ++i ) { Data[i] = Adjective[rand()%n_adjective]; Data[i] += " "; Data[i] += Noun[rand()%n_noun]; } } static void ParseCommandLine( int argc, char* argv[] ) { int i = 1; if( i<argc && strcmp( argv[i], "verbose" )==0 ) { Verbose = true; ++i; } if( i<argc && !isdigit(argv[i][0]) ) { fprintf(stderr,"Usage: %s [verbose] number-of-threads ", argv[0]); exit(1); } if( i<argc ) NThread = strtol(argv[i++],0,0); } int main( int argc, char* argv[] ) { srand(2); ParseCommandLine( argc, argv ); CreateData(); CountOccurrences(); } 安全でないコンテナの使用を検知するこの例をMicrosoft Visual Studio .NET 2005でコンパイルし、4プロセッサのIntel Xeonサーバー上で4つのスレッドによって実行してください。そうすれば、何か問題があることがすぐに分かるでしょう。31行目によって報告される合計値が実行のたびに変わり、決してN(1000000)と等しくなりません。競合状態やその他のスレッド化エラーがあるのかもしれません。 この点を確認するため、潜在的なデータ競合やデッドロックやその他のスレッド化エラーを検出するツール(Intel Thread Checker)でこの例を実行してみました。Intel Thread Checkerにより、スレッド間の潜在的な依存関係を検知し、その依存関係をエラーの元になっているソース行にマップすることができました。これらの潜在的な依存関係は、すべて同じ箇所(8行目)を指していました。これは、スレッドがSTLのmapにアクセスするところです(図1を参照)。 スケーラブルなソリューションを見つける 図1を見ると、mapへのアクセスを同期化する必要があることが明らかになります。まず、単一のWin32 Mutexまたは Win32 Mutexは全プロセスから見えるカーネルオブジェクトです。単一の 図2に、STLのmapを使って同期化を行うバージョンのパフォーマンスを、同じコードをシーケンシャルに実装した場合を1として示します。 同期がなければ、STLのmapは良好なパフォーマンスをもたらしますが、同時アクセスが行われると問題が起き、誤った結果を引き起こします。Win32 Mutexはコストが高いため、予想どおり劣悪なパフォーマンスをもたらします。単一の 図3は、STLのmapをTBBの つまり、TBBの並列コンテナの同時並行性を利用すると、安全でないコンテナに粒度の粗いロックを適用する方法ではとても実現できない高いパフォーマンスが可能になります。 これからはますます多くの開発者が、マルチコアシステムへの移行という避けがたい事態に直面することになるでしょう。こうした開発者にとっては、マルチスレッドアプリケーション開発でエラーを起きにくくするためのツールを探求することが絶対必要になります。TBBに含まれている並列コンテナクラスもそうしたものの1つであり、マルチコアへの移行を容易にしてくれます。これらの並列コンテナクラスが提供する安全でスケーラブルな実装のおかげで、C++ STLで定義されているようなスレッドフレンドリでないコンテナを使わずに済み、このようなコンテナに伴う諸々の問題を回避できるからです。 著者紹介Michael Voss(Michael Voss)
Intel社のSenior Staff Software Engineer。2001年にPurdue大学からElectrical Engineering博士号を授与された。現在、IntelのThreading LabとToronto大学の非常勤講師を掛け持つ。平行プログラミングとコンパイラの最適化に関心を寄せている。
|