oneTBBの使い方¶
C++ Advent Calendar 2024 22日目の記事です。 今回は意外と少なかったoneTBB(旧intel TBB)の使い方について書きます。
oneTBBとは¶
oneTBBとはintelが中心となって開発している、お手軽マルチスレッディングライブラリです。
C++20ではセマフォやバリアといった多数の同期処理に関する機能が追加されましたが、これらを使ってマルチスレッドなプログラムを書くのは慣れてないとなかなか大変だと思います。
oneTBBではfor
文やreduce
、sort
といった処理を並列処理する関数やスレッドセーフなコンテナを用意しており、並列処理に詳しくなくても書けるように設計されています。
名前空間はoneapi::tbb
で、oneapi/tbb.h
をインクルードすれば全機能が使えます。
では、実際にどんな関数があるのか見ていきましょう。
単純な並列処理¶
ここでは、比較的単純な並列処理を行う関数について見ていきます。
前提知識¶
以下で紹介する関数は引数にRange
型という、何らかの範囲を表すクラスのインスタンスを指定します。
Note
parallel_for
には指定しない関数オーバーロードもありますが、並列実行されるとは限らないため省略します1。
これは例えば
の計算のように、各要素の計算は時間がかからないが要素数が多い場合、スレッド間の同期処理によるオーバーヘッドを減らすために導入されています。
いくつか種類がありますが3、blocked_range
を知っていれば大体のケースは問題ないでしょう。
namespace oneapi::tbb {
template<typename Value>
class blocked_range {
//! Type of a value
/** Called a const_iterator for sake of algorithms that need to treat a blocked_range as an STL container. */
using const_iterator = Value;
//! Type for size of a range
using size_type = std::size_t;
//! Construct range over half-open interval [begin,end), with the given grainsize.
blocked_range( Value begin_, Value end_, size_type grainsize_=1 );
//! Beginning of range.
const_iterator begin() const;
//! One past last value in range.
const_iterator end() const;
//! Size of the range
size_type size() const;
//! True if range is empty.
bool empty() const;
};
}
ここでは、ユーザーが知っていればいいものだけを抜き出しました。
このクラス2は0以上の整数の範囲 (正確には右半開区間) を表すために使われます。
テンプレートパラメーターのValue
は普通はstd::size_t
を指定すれば良いです4。
コンストラクタで範囲を指定しましょう。
Note
begin
の戻り値はイテレータではなくValue
型の値です。
間違えやすい上によく使うので、注意しましょう。
以下の関数では、渡されたRange
型のインスタンスを分割して、各スレッドで分割後の範囲を実行しています。
parallel_for
¶
namespace oneapi::tbb {
template<typename Range, typename Body>
void parallel_for(const Range& range, const Body& body);
}
for文のように、各要素に対してbody
を並列に実行する関数です。
Body
型にも要件がありますが5、[...](const Range& r) {...}
で十分です。
以下はparallel_for
を使った簡単な例です。
Note
parallel_for
はループ全体で少なくとも100万命令以上ある処理に対して使いましょう。
parallel_reduce
¶
namespace oneapi::tbb {
template<typename Range, typename Value, typename Func, typename Reduction>
Value parallel_reduce(const Range& range, const Value& identity, const Func& func, const Reduction& reduction);
}
parallel_reduce
6はstd::reduce
のように、与えられた範囲range
を集計する関数です。集計順は
range
以外の仮引数の要件は、単純化すると以下の通りです。
identity
: コピー構築可かつコピー代入可な型の左単位元(x == reduction(identity, x)
を満たす値)。func
の仮引数x
に渡されます。func
:[...](const Range& r, const Value& x) -> Value {...}
を渡せば十分。この関数は初期値x
にr
の範囲の値を集計するために使われます。reduction
:[...](const Value& x, const Value& y) -> Value {...}
を渡せば十分。この関数は2つの値をまとめるために使われます。
以下はparallel_reduce
を使った簡単な例です。
多数のstd::set
をマージすることも出来ます。7
Note
parallel_reduce
には実はもう一つ
複雑な並列処理¶
この節で登場する関数は一部シングルスレッドで動作します。 紹介しておいてなんですが、なるべく使わない方が良いと思います。
parallel_for_each
¶
namespace oneapi::tbb {
// (1)
template<typename InputIterator, typename Body>
void parallel_for_each( InputIterator first, InputIterator last, Body body );
// (2)
template<typename Container, typename Body>
void parallel_for_each( Container& c, Body body );
// (3)
template<typename Container, typename Body>
void parallel_for_each( const Container& c, Body body );
}
この関数はループ回数がわからないけど、各要素を並列処理したいという時に使います。 一見便利に見えますが、要素アクセスがシングルスレッドで動作するので注意しましょう。
テンプレートパラメーターInputIterator
は入力イテレータでなければいけません。
body
はInputIterator
が前方向イテレータかどうかでwell-definedな引数が決まります。
説明のため
とすると、
InputIterator
が前方向イテレータでない場合、以下のラムダ式をbody
に渡せます。InputIterator
が前方向イテレータの場合、1に加えて以下のラムダ式もbody
に渡せます。
オーバーロードの2と3はparallel_for_each(std::begin(c), std::end(c), body)
と同じです。
parallel_pipeline
¶
namespace oneapi::tbb {
void parallel_pipeline(size_t max_number_of_live_tokens, const filter<void,void>& filter_chain);
}
この関数は工場の流れ作業のように、filter_chain
に与えられた関数を実行します。
詳しい説明の前に具体例を見た方が早いでしょう。
見ての通り、filter_chain
にはoneapi::tbb::make_filter
8で作成したfilter
を渡します。
別のフィルターを後ろにつけるには&
で繋げます。
フィルター間で受け渡しされる値をトークンとすると、max_number_of_live_tokens
は並列実行可能なトークンの最大数です11。
make_filter
¶
namespace oneapi::tbb {
template<typename InputType, typename OutputType, typename Body>
filter<InputType, OutputType> make_filter(filter_mode mode, const Body& body);
}
InputType
には前のフィルターから受け取る値の型を、OutputType
には後ろのフィルターに渡す値の型を書きます。
ない場合はvoid
です。
mode
には以下の表の値を指定します。9
すべてoneapi::tbb
名前空間の下にあります。
mode |
意味 |
---|---|
parallel |
並列実行 |
serial_in_order |
順番通りに逐次実行 |
serial_out_of_order |
順不同に逐次実行 |
body
は最初のフィルターでない限り、[...](InputType&&) -> OutputType {...}
を渡せば良いです(body
には右辺値が渡されます10)。
最初のフィルターの場合、[...](oneapi::tbb::flow_control& fc) -> OutputType {...}
を渡せば良いです。
ただし、関数内でfc.stop()
を呼んでパイプラインを終わらせる必要があります。
その他¶
parallel_sort
¶
namespace oneapi::tbb {
// (1)
template<typename RandomAccessIterator>
void parallel_sort(RandomAccessIterator begin, RandomAccessIterator end);
// (2)
template<typename RandomAccessIterator, typename Compare>
void parallel_sort(RandomAccessIterator begin, RandomAccessIterator end, const Compare& comp);
// (3)
template <typename Container>
void parallel_sort(Container&& c) {
parallel_sort(std::begin(c), std::end(c));
}
// (4)
template <typename Container, typename Compare>
void parallel_sort(Container&& c, const Compare& comp) {
parallel_sort(std::begin(c), std::end(c), comp);
}
}
与えられた半開区間[begin, end)
またはコンテナc
を、std::less
またはcomp
を用いて並列にソートする関数。
イテレータRandomAccessIterator
はランダムアクセスイテレータで、値はムーブ可能でなければならない。
また、比較オブジェクトcomp
はbool operator()(const T&, const T&)
(T
は値型)を実装していなければならない。
FAQ¶
Parallel STLがあるからTBB要らないんじゃない?¶
簡単な並列処理ならPSTLを使ったほうが楽だと思いますが、TBBを直に使う方がより複雑な並列処理ができます。
実装の詳細までは知りませんが、TBBではちゃんと設定しないとparallel_for
の中で他の並列処理を呼ぶと正しく処理してくれません。なので、そういう処理を行う際は必要になるかなと思います。
あと説明していませんでしたが、TBBにはFlow Graphという命令並列な並列処理を行う機能があります。 また、タスクの中断時の挙動の設定やエラー処理もTBBにはあるので、こういう機能で差別化できているかなと思います。
余談¶
parallel_reduce
の大量のstd::set
をマージするという例は実は私がコードを書いていた中で出てきた問題でした。
oneTBB
にイシューを立てたところ、Body
を使った方法を親切に教えてもらいました。
intelは最近あまり良い話題がありませんが、何らかの形で恩返し出来たらなと思います。
PSTLの実装について¶
実はGNU、LLVMどちらも内部でTBBを使っています。 具体的には、GNUはLLVMの実装を使っていて、フラグでTBBを使うようにしており12、 LLVMの方もデフォルトでは逐次実行ですが、TBBを使うフラグを用意しています13。
LLVMの実装の理由は環境依存の依存関係を作りたくないからのようですが、使う際は逐次実行の可能性もあるため、どのようにビルドしたかまで調べる必要があります。 気持ちはわかるんですが、こんなことされるとPSTL使いたくないなと思ってしまいました。