インテル® oneAPI スレッディング・ビルディング・ブロック (インテル® oneTBB) クックブック

インテル® oneTBB

この記事は、インテルのウェブサイトで公開されている「Intel® oneAPI Threading Building Blocks Cookbook」の日本語参考訳です。原文は更新される可能性があります。原文と翻訳文の内容が異なる場合は原文を優先してください。


このクックブックでは、複雑なアプリケーションに並列処理を実装する作業を簡素化する柔軟な C++ ライブラリーであるインテル® oneTBB を使用して、コードを並列化および拡張するための学習教材とユースケースの使用例 (レシピ) を紹介します。インテル® oneTBB の詳細については、次のドキュメントを参照してください。

並列実行によるアプリケーションの開発

このレシピでは、並列実行を備えたアプリケーションを開発する方法について説明します。ここでは、インテル® oneTBB を使用して配列要素の合計を計算します。

コンテンツ作成者: Alexei Katranov、Pavel Kumbrasev

使用するもの

以下は、このレシピで使用する最小ハードウェアとソフトウェアの要件です。サポートされているすべてのオプションを確認するには、インテル® oneTBB のシステム要件を参照してください。

  • コンパイラー: インテル® oneAPI ツールキットで提供されるインテル® oneAPI DPC++/C++ コンパイラー。
  • ライブラリー: インテル® oneTBB のみ。
  • • オペレーティング・システム:
    • Windows 10
    • Linux
    • macOS 10.15、11.x
    • Android 9
  • ハードウェア:
    • インテル® Core™ プロセッサー・ファミリー
    • インテル® Xeon® プロセッサー・ファミリー

手順

問題を理解する

配列要素の合計を計算する単純なタスクの並列化にはインテル® oneTBB を使用できます。まず、この問題のシリアルバージョンは次のようになります。

int summarize(const std::vector<int>& vec) {
    int sum = 0;
    for (int i = 0; i < vec.size(); ++i) {
    sum += vec[i];
    }
    return sum;
}

アルゴリズムを並列に実行するには、互いに独立して処理できる領域に分割する必要があります。最も単純な方法は、処理される要素を複数に分割し、それぞれをストリームで処理することです。

しかし、このコードは複雑なため、そのようにはできません。すべての要素は 1 つの変数に累積されますが、その変数にアクセスするスレッドの 1 つがこの変数に書き込むと同時に、別のスレッドが同じ変数を読み取ったり書き込んだりするとデータ競合が発生します。

ヒント: データ競合とは、アクセスの 1 つが更新である場合、2 つ以上のスレッドが同じメモリー位置に同時に非同期的にアクセスすることです。

代入オペレーター (オペレーター =+) は、メモリーからの読み取り、加算、および結果をメモリーへ格納という 3 つの操作で構成されます。これらの操作は異なるスレッドによっても並列に実行される可能性があり、予期しない結果を招くことがあります。2 つのスレッドがタイムライン上で実行可能な操作の順序はいくつかあります。これが複雑である原因は、両方のスレッドが別のスレッドによる操作の結果を取得できず、無効な値を上書きする可能性があることです。C++ ではこのような状況をデータ競合と見なし、プログラムの動作は未定義となります。例えば、プログラムの実行結果として、6 を予想していたのに 4 が返される場合があります。これは、プログラムが実行する操作の順序によって異なる結果を示す可能性があることを意味します。

データ競合に対処する C++ インターフェイスは多数ありますが、最も単純なミューテックスについて考えてみましょう。ミューテックスには、ロックとロック解除という 2 つの主なインターフェイスがあります。ロックはミューテックスを排他的に占有し、アンロックはそれを解放して他のスレッドでも使用できるようにします。ミューテックスを取得できないスレッドは、別のスレッドがミューテックスを解放するまで待機してブロックされます。

ヒント: ミューテックスの概念は、アトミック操作よりもはるかに単純です。これにより、任意の時点で 1 つのスレッドのみで実行できるクリティカル・セクションを作成できます。さらに、shared_lock のような高度なミューテックスもあり、クリティカル・セクションの作業効率を向上できます。

ロックで保護されたコード領域を「クリティカル・セクション」と呼びます。重要なことは、最初のスレッドがクリティカル・セクションにある間、ミューテックスのロックに失敗した 2 番目のスレッドは重要な処理を何も行わないということです。したがって、クリティカル・セクションの大きさは、プログラムのパフォーマンス、さらにはシステム全体のパフォーマンスに重大な影響を与える可能性があります。

スレッド・ライブラリーを使用したアプリケーションの並列化

最後に、例を並列化してみましょう。スレッドを作成するには、C++ 標準ライブラリーのスレッド・ライブラリーを使用します。

int summarize(const std::vector<int>& vec) {
    int num_threads = 2;
    std::vector<std::thread> threads(num_threads);

    int sum = 0;
    std::mutex m;
    auto thread_func = [&sum, &vec, &m] (int thread_id) {
        // 元のレンジを 2 つに分割
        int start_index = vec.size() / 2 * thread_id;
        int end_index = vec.size() / 2 * (thread_id + 1);
        for (int i = start_index; i < end_index; ++i) {
            // RAII idiom を実装する lock_guard を使用
            // - ミューテックスはコンストラクションで取得 (mutex.lock() が呼び出し)
            // - ミューテックスは破棄によって解放 (mutex.unlock() が呼び出し)
            std::lock_guard<std::mutex> lock(m);
            sum += vec[i];
        }
    };

    for (int thread_id = 0; thread_id < num_threads; ++ thread_id) {
    // 開始関数 `thread_func` を関数引数 `thread_id` でスレッドを開始
        threads[thread_id] = std::thread(thread_func, thread_id);
    }

    std::cout << sum << std::endl;

    for (int thread_id = 0; thread_id < num_threads; ++ thread_id) {
        // 破棄前にすべてのスレッドを待機
        threads[thread_id].join();
    }
    return sum;
}

プログラムをコンパイルして実行すると、おそらく間違った結果が表示されます。その理由は、ミューテックスが合計を計算する際にデータを競合から保護しますが、他のスレッドが sum 変数を変更している間、メインスレッドはそれを読み取ることができるためです。ミューテックスを使用して読み取りを保護しても、競合状態と呼ばれる複雑な状況が発生します。

ヒント: 競合状態は、プログラムを実行した結果が、異なるスレッドによって実行される操作のシーケンスに依存し、アプリケーションが再度実行すると結果が変わる可能性がある状況を示す一般的な用語です。

この場合、計算が完全に完了するまで待機する必要はありません。この問題を解決するには、結果を読み取る前にスレッドの完了を待機します。ただし、計算の同期はスレッドを待機している間に行われるため (join 関数を使用)、合計の読み取りにミューテックスは必要ありません。

int summarize(const std::vector<int>& vec) {
    int num_threads = 2;
    std::vector<std::thread> threads(num_threads);

    int sum = 0;
    std::mutex m;
    auto thread_func = [&sum, &vec, &m] (int thread_id) {
        // 元のレンジを 2 つに分割
        int start_index = vec.size() / 2 * thread_id;
        int end_index = vec.size() / 2 * (thread_id + 1);
        for (int i = start_index; i < end_index; ++i) {
            // RAII idiom を実装する lock_guard を使用
            // - ミューテックスはコンストラクションで取得 (mutex.lock() が呼び出し)
            // - ミューテックスは破棄によって解放 (mutex.unlock() が呼び出し)
            std::lock_guard<std::mutex> lock(m);
            sum += vec[i];
         }
    };

    for (int thread_id = 0; thread_id < num_threads; ++ thread_id) {
        // 開始関数 `thread_func` を関数引数 `thread_id` でスレッドを開始
        threads[thread_id] = std::thread(thread_func, thread_id);
    }

    for (int thread_id = 0; thread_id < num_threads; ++ thread_id) {
        // 破棄前にすべてのスレッドを待機
        threads[thread_id].join();
    }

    std::cout << sum << std::endl;

    return sum;

}

この並列化のアプローチは、sum += vec[i] ごとにミューテックス std:: lock_guard<std::mutex>lock(m) を取得するため、シリアルバージョンよりも遅くなります。したがって、計算は完全にシリアル化され、一度に実行されるスレッドは 1 つだけになります。これを回避するには、まず各スレッド内でローカル sum を計算し、最後にその結果をグローバル sum に加算します。

int sum = 0;
std::mutex m;
auto thread_func = [&sum, &vec, &m] (int thread_id) {
    // 元のレンジを 2 つに分割
    int start_index = vec.size() / 2 * thread_id;
    int end_index = vec.size() / 2 * (thread_id + 1);
    int local_sum = 0;
    for (int i = start_index; i < end_index; ++i) {
        local_sum += vec[i];
    }

    // RAII idiom を実装する lock_guard を使用
    // - ミューテックスはコンストラクションで取得 (mutex.lock() が呼び出し)
    // - ミューテックスは破棄によって解放 (mutex.unlock() が呼び出し)
    std::lock_guard<std::mutex> lock(m);
    sum += local_sum;
};

インテル® oneTBB によるアプリケーションの並列化

この単純な例は、並列プログラミングでは、シリアルプログラムでは確認できないいくつかの問題が発生することを示しています。さらに、これらの問題は必ずしも容易に検出できるわけではなく、明らかであるわけでもありません。インテル® oneTBB などのライブラリーは、多くの面で並列プログラミングを簡素化します。例えば、この例は、競合状態を回避するのに特別な同期やメカニズムを必要としない parallel_reduce を使用して書き直すことができます。

int summarize(const std::vector<int>& vec) {
    int sum = tbb::parallel_reduce(tbb::blocked_range<std::size_t>{0, vec.size()}, 0,
    [&vec] (const auto& r, int init) {
        for (auto i = r.begin(); i != r.end(); ++i) {
            init += vec[i];
        }
        return init;
    },
    std::plus<int>{});

    return sum;
}

まとめ

この例は小規模ですが、インテル® oneTBB が提供する一連の強力な簡素化を示しています。例えば、インテル® oneTBB は、並列アルゴリズムの複数の呼び出し間で再利用されるスレッドプールを管理します。さらに、parallel_reduce は必要なすべての同期を実装するため、std::plus<int> などの操作を記述するだけで済みます。インテル® oneTBB は、幅広いアプリケーションに適用できる一連の並列アルゴリズムを提供します。このライブラリーは、ワーク・スチール・アプローチを使用して、スレッド間でタスクを分散します。インテル® oneTBB アプローチの主な利点は、アプリケーションのさまざまな独立したコンポーネントで並列処理を簡単に構成できることです。

タイトルとURLをコピーしました