TTreeProcessorMT など ROOT のマルチスレッド化について調べたのでまとめてみる。
TTreeProcessorMT を用いた場合の基本形
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
ROOT::EnableImplicitMT(); ROOT:TTreeProcessorMT tp(“rootFile.root”,”treeName”); ROOT::TThreadedObject<TH1F> hist(“hist”,””,10,0.,1.); auto myFunction = [&] (TTreeReader &myReader){ // branch TTreeReaderValue<int> val(myReader,"branchName"); // histogram auto t_hist=hist.Get(); while(myReader.Next()){ t_hist.Fill(*val) } }; tp.Process(myFunction); auto histMerged=hist.Merge(); histMerged->Draw(); |
ROOT::EnableImplicitMT(#thread)
ROOT内部で暗黙にマルチスレッド処理を行うようにする。
#thread を指定しない、或いは0(ゼロ)を指定すると、
システムのコア数かスレッド数を勝手に参照する。
実はこの EnableImplicitMT は TTreeProcessorMT だけでなく、
内側のいろんなところで参照されている。
v6.16 の doxygen によると
- RDataFrame internally runs the event-loop by parallelizing over clusters of entries
- TTree::GetEntry reads multiple branches in parallel
- TTree::FlushBaskets writes multiple baskets to disk in parallel
- TTreeCacheUnzip decompresses the baskets contained in a TTreeCache in parallel
- THx::Fit performs in parallel the evaluation of the objective function over the data
- TMVA::DNN trains the deep neural networks in parallel
- TMVA::BDT trains the classifier in parallel and multiclass BDTs are evaluated in parallel
と、かなり色々な所が勝手にマルチスレッド処理されるようになるようです。
というわけで、後述するように既存のコードの冒頭におまじないのように
ROOT::EnableImplicitMT();
を追加するだけで速くなる。
内部でROOT::EnableThreadSafety()
も合わせて呼んでいる。
branch へのアクセス
TTree::SetBranchAddress に相当する箇所。
C++ の基本型やクラス: TTreeReaderValue
TreeProcessorMT::Process(...) に渡す関数内で、
TTreeReaderValue<T> val(objTTreeReader, "branchName")
で指定。
TTreeReader::Next() のループ内において、
指定した branch が *val や val->GetXXX(...) などポインタ的な形で扱える。
配列: TTreeReaderArray
TreeProcessorMT::Process(...) に渡す関数内で、
TTreeReaderArray<T> array(objTTreeReader, "branchName")
で指定。
TTreeReader::Next() のループ内において、
指定した branch が array[1] のような形で扱える。
今のところの理解では、多次元配列がやや面倒っぽい。
TTreeReaderArray<T>
が1次元の配列なので、T* array2d[N] とかして二次元配列的にアクセスしようと考えるが、
実体は TTreeReader::Next() が呼ばれてはじめて生成されるようだ。
という訳で以下のように書くのがベターだと思われる、多分。。。
1 2 3 4 5 6 7 8 9 |
TTreeReaderArray<T> array(...); T* array2d[N]; // array2d[N][M] を想定 objTTreeReader.Next(); for ( int i=0;i<N;i++ ){ array2d[i]=array.begin()+i*M; } // 最初のエントリーはすでに load 済 do{ ... }while(objTTreeReader.Next()); |
というように、普段はあまり使わない(?) 後置while を使うと簡潔に書ける。
並列度
二次元配列の branch を取ってきて TH1F に詰めるだけの簡単なコードを
TTree::SetBranchAddress()
やTTree::GetEntry()
などの従来方式と、
TTreeProcessorMT
やTTreeReader
などを用いた新方式でなるべく同じように書いて、
どの程度の恩恵が得られそうか、並列度を調べてみた。
プログラム | 時間 |
---|---|
従来方式(TTree::SetBranchAddress/GetEntry) | 11.7 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(1) | 5.6 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(2) | 3.3 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(3) | 2.5 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(4) | 2.2 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(10) | 1.5 s |
新方式(TTreeProcessorMT) + ROOT::EnableImplicitMT(20) | 1.3 s |
スレッド数に応じて、処理速度の伸びは小さくなり漸近しているものの、
逆を言うと、ただTreeを読んでヒストグラムに詰めるだけの簡単な処理でも、
スレッド数に応じて速くなり続けているともいえる。
これら全てで全く同一のヒストグラムが得られることを確認した。
特筆すべきは、EnableImplicitMT(1) の場合ですら大幅に高速化している点。
そこで従来方式のままのコードの冒頭に
EnableImplicitMT
だけを追加して調べてみた。プログラム | 時間 |
---|---|
従来方式(再掲) | 11.7 s |
従来方式 + ROOT::EnableImplicitMT(1) | 11.4 s |
従来方式 + ROOT::EnableImplicitMT(2) | 10.1 s |
従来方式 + ROOT::EnableImplicitMT(4) | 9.5 s |
従来方式 + ROOT::EnableImplicitMT(10) | 9.4 s |
前述したように TTreeProcessorMT だけでなく、
他のあちこちで EnableImplicitMT() による設定を参照しているため、
冒頭に追加するだけで簡単に高速化できる。
このように既存のクラスやメソッドでも密かに並列化が進められており、
おそらく TTreeProcessorMT やその関連クラスはかなり最適化されていると思われます。
それにしても 5.6秒 は随分速いなぁ。。。
注意点
冒頭で THx::Fit も並列化していると説明したが、
1回のフィット操作が内部でマルチスレッド化されただけであり、
マルチスレッドの個々の処理の中で Fit を非同期に行うことは、
以前と相も変わらず、やっぱりできません。
するなら fitting 専門のスレッドを陽に立ててキューとして渡すか、
以下のように Fit の前後をロックする必要がある。
1 2 3 4 5 |
{ std::lock_guard<std::mutex> lock(mtx); TH1::Fit(...); ... } |
なお、ロックをしないといけないと言うことは、
裏を返すと何か共通の変数を読み書きしていると言うことなので、
実際に試してみると後者のようにしても
シングルスレッド時と完全には同一の結果が得られないので注意が必要です。
むしろ良いやり方、正しいやり方をご存知の方がいたら是非ご教授ください。
感想 という名のタワゴト
ROOT という同じフレームワークにおいて、
同じようにマルチスレッド化しようとするのだから、
当然といえば当然なのだけれど、
どうにも 約10年前に作った 拙作 TThreadUtil の実装に似てる気もする。
bool XXX::Next(), XXX::Merge() とかテンプレートで実装とか。
まぁ私の TThreadUtil は TTree の解析だけでなく、
TTree を用いない汎用用途でも利用できるようにする点と、
なるべく既存の(シングルスレッドの)コードを
そのままコピペ移植で並列化できる点を目的に書いてあるので、
完全には被らない訳だけど。
この時は(も?)、fitting が必須の処理を TTree で多量に処理したかったのだけれど、
thread処理内で TH1::Fit などが同時に使えないので、
作り込みはほどほどで止まっちゃったんだよなぁ。
(マルチスレッドの個々の処理内で fitting ができないのはコンニチも変わらずだが。。。)
fitting が必要な処理に関しては、各スレッドから fit を伴うプロセスを呼んで、
各プロセスを掴んでる各スレッドをこの TThreadUtil で管理して、
擬似マルチプロセス的な使い方をしてました。
処理時間の長短のが異なる無数の処理(プロセス)を、
CPUコアが空き次第どんどん投げて空きを埋めるなんて使い方もできます。
。。。
まぁ TTree::GetEntry とか THx::Fit など、
内部で勝手にマルチスレッド動作してくれるのは良いことですね。