この記事は、インテル® デベロッパー・ゾーンに公開されている「An Efficient Parallel Three-Way Quicksort Using Intel C++ Compiler And OpenMP 4.5 Library」の日本語参考訳です。
この記事の PDF 版はこちらからご利用になれます。
はじめに
この記事では、有名なヒープソートやマージソート・アルゴリズムよりも、漸近的に高速で効率良い並列 3 分岐基数クイックソートを実装する C++11 コードを紹介します。また、qsort(…) (ANSI C) や std::sort(…) (ISO/IEC 14882(E)) などの既存の高速なクイックソート実装と比較して、優れたパフォーマンスをもたらす並列コードについても説明します。
具体的には、3 分岐基数クイックソート・アルゴリズムとその複雑性について調べて、インテル® C++ コンパイラーと OpenMP* 4.5 ライブラリーを使用して、ソートを並列に実行する現代的なコードの記述方法を詳しく述べます。OpenMP* の並行タスクを使用して再帰的サブソートを並列化し、アルゴリズム全体のパフォーマンスを大幅に向上する方法を説明します。
そして、インテル® Core™ i9 プロセッサー・ファミリーやインテル® Xeon® プロセッサー E5 ファミリーなど、インテル製の対称型マルチコア CPU を搭載したマシンで並列ソートを行うサンプルプログラムを実行して、C++ 標準ライブラリーの std::sort(…) 実装のパフォーマンスと比較して評価します。
3 分岐基数クイックソート・アルゴリズム
3 分岐基数クイックソートは、配列全体をピボット要素の値より小さい、等しい、または大きい要素で構成される 3 つのパーティションに分割します。そして、左右のパーティションを同じように 3 分割することで再帰的にソートします。3 分岐基数クイックソートは、膨大な要素数の配列ソートを可能にするだけでなく、ベストケースのソートの複雑性を準線形 O(n log n) から線形 O(n) に軽減します。以下のアルゴリズムは、ヒープソートやマージソート・アルゴリズムよりも、O((n log n) / n) = O(log n) 倍高速です。さらに、3 分岐基数クイックソートは、キャッシュ・コヒーレントであり、CPU のキャッシュ・パイプラインに大きな影響を与えるため、一般にソート全体のパフォーマンスにも影響します。
3 分岐基数クイックソート・アルゴリズムは、配列内の各要素を左から右へ一度だけパススルーします。配列内の各要素は、以下の 3 分岐比較によりピボット値と比較されます。3 分岐比較は、要素がピボット値よりも小さいか、大きいか、または等しいかの 3 つのケースを処理します。要素がピボット値よりも小さい場合は左のパーティションと交換し、要素がピボット値よりも大きい場合は右のパーティションと交換します。要素がピボット値と等しい場合、交換は行われません。この処理では、left、right、および i の 3 つのインデックスを使用します。インデックス i は配列内の各要素へのアクセスに使用され、インデックス left と right は左右のパーティションとの要素の交換に使用されます。
3 分岐基数クイックソート・アルゴリズムは、次のように定義できます。
- 要素の配列は arr[0..N] とし、最初と最後の要素のインデックスは low と high とします。
- 最初の要素の値をピボットにします (pivot = arr[low])。
- left 変数を最初の要素のインデックスに初期化します (left = low)。
- right 変数を最後の要素のインデックスに初期化します (right = high)。
- 変数 i を第 2 要素のインデックスに初期化します (i = low + 1)。
- 配列の各 i 番目の要素 arr[i] (i <= high) に対して、次の処理を行います。
i 番目の要素 arr[i] とピボット値を比較します。
- i 番目の要素 arr[i] がピボット値よりも小さい場合、left インデックスの要素と交換して、left インデックスと i インデックスを 1 つインクリメントします。
- i 番目の要素 arr[i] がピボット値よりも大きい場合、right インデックスの値を交換して、right インデックスを 1 つデクリメントします。
- i 番目の要素 arr[i] がピボット値と等しい場合、交換は行わず、インデックス i を 1 つインクリメントします。
- 配列の左のパーティション arr[low..left – 1] を再帰的にソートします。
- 配列の右のパーティション arr[right + 1..high] を再帰的にソートします。
3 分割を実行後、以下のアルゴリズムは左右のパーティションのソートを個別に再実行します。この処理は、再帰的なソートを並列に実行する 2 つの並行タスクをスポーンすることで行えます。
効率良い並列ソート
以下の現代的な C++11 コードは、3 分岐基数クイックソート・アルゴリズムを実装します。
namespace internal { std::size_t g_depth = 0L; const std::size_t cutoff = 1000000L; template<class RanIt, class _Pred> void qsort3w(RanIt _First, RanIt _Last, _Pred compare) { if (_First >= _Last) return; std::size_t _Size = 0L; g_depth++; if ((_Size = std::distance(_First, _Last)) > 0) { RanIt _LeftIt = _First, _RightIt = _Last; bool is_swapped_left = false, is_swapped_right = false; typename std::iterator_traits<RanIt>::value_type _Pivot = *_First; RanIt _FwdIt = _First + 1; while (_FwdIt <= _RightIt) { if (compare(*_FwdIt, _Pivot)) { is_swapped_left = true; std::iter_swap(_LeftIt, _FwdIt); _LeftIt++; _FwdIt++; } else if (compare(_Pivot, *_FwdIt)) { is_swapped_right = true; std::iter_swap(_RightIt, _FwdIt); _RightIt--; } else _FwdIt++; } if (_Size >= internal::cutoff) { #pragma omp taskgroup { #pragma omp task untied mergeable if ((std::distance(_First, _LeftIt) > 0) && (is_swapped_left)) qsort3w(_First, _LeftIt - 1, compare); #pragma omp task untied mergeable if ((std::distance(_RightIt, _Last) > 0) && (is_swapped_right)) qsort3w(_RightIt + 1, _Last, compare); } } else { #pragma omp task untied mergeable { if ((std::distance(_First, _LeftIt) > 0) && is_swapped_left) qsort3w(_First, _LeftIt - 1, compare); if ((std::distance(_RightIt, _Last) > 0) && is_swapped_right) qsort3w(_RightIt + 1, _Last, compare); } } } } template<class BidirIt, class _Pred > void parallel_sort(BidirIt _First, BidirIt _Last, _Pred compare) { std::size_t pos = 0L; g_depth = 0L; if (!misc::sorted(_First, _Last, pos, compare)) { #pragma omp parallel num_threads(12) #pragma omp master internal::qsort3w(_First, _Last - 1, compare); } } }
このコードは、通常、データフローの依存関係により並列に実行できないため、3 分割をシーケンシャルに実行します。また、このケースでは、3 分割の複雑性は常に O(n) であり、十分なパフォーマンスの向上が得られることから、並列処理を最適化する必要はありません。
そのため、再帰的なソートを行うコードを簡単に並列実行して、ソート全体のパフォーマンスを大幅に向上できます。再帰的なソートを並列に実行するため、#pragma omp taskgroup {} ディレクティブを使用して、実行時に 2 つの OpenMP* 並行タスクを生成するコードを実装します。これらのタスクは両方とも OpenMP* の #pragma omp task untied mergeable {} ディレクティブによりスケジュールおよび起動され、個別のスレッドで再帰的なソートを実行します。ここでは、以下のタスクが複数のスレッドで実行されるように untied 節を使用しています。同様に、タスクが生成するコードと同じデータ・コンテキストを使用するように mergeable 節を指定しています。
if (_Size >= internal::cutoff) { #pragma omp taskgroup { #pragma omp task untied mergeable if ((std::distance(_First, _LeftIt) > 0) && (is_swapped_left)) qsort3w(_First, _LeftIt - 1, compare); #pragma omp task untied mergeable if ((std::distance(_RightIt, _Last) > 0) && (is_swapped_right)) qsort3w(_RightIt + 1, _Last, compare); } } else { #pragma omp task untied mergeable { if ((std::distance(_First, _LeftIt) > 0) && is_swapped_left) qsort3w(_First, _LeftIt - 1, compare); if ((std::distance(_RightIt, _Last) > 0) && is_swapped_right) qsort3w(_RightIt + 1, _Last, compare); } }
スケジュールされる最初のタスクが左のパーティションのソートを実行し、2 つ目のタスクが右のパーティションのソートを実行します。
上記のコードは、条件付きタスクを実行します。タスクを生成する前に、空のパーティションをソートしないように、パーティションをソートする必要があるかどうかをチェックします。ソートの必要がある場合、ソートを実行するため、qsort3w(…) 関数を再帰的に呼び出すタスクをスポーンします。
並列 3 分岐基数クイックソートに効果的な最適化方法があります。重複する要素が多い配列をソートする場合、ソートの再帰の深さが増加し、コードが膨大な数の並列タスクを生成することで、同期とタスク・スケジュールのオーバーヘッドが大きくなることがあります。このような問題が発生しないように、最初にソートする配列のサイズがしきい値を超えていないかチェックするコードを実装します。しきい値を超えていなければ、通常、前述のように 2 つの並行タスクを生成します。しきい値を超える場合は、qsort3w(…) 関数の再帰呼び出しをマージして 1 つのスレッドで実行します。これにより、スケジュールされる並列再帰タスクの数を減らします。
ソートの全プロセスは、qsort3w(…) 関数を呼び出すことから始まります。
template<class BidirIt, class _Pred > void parallel_sort(BidirIt _First, BidirIt _Last, _Pred compare) { std::size_t pos = 0L; g_depth = 0L; if (!misc::sorted(_First, _Last, pos, compare)) { #pragma omp parallel num_threads(12) #pragma omp master internal::qsort3w(_First, _Last - 1, compare); } }
OpenMP* の task ディレクティブではなく、並列領域のマスタースレッドで qsort3w(…) を呼び出すことを推奨します。
C++11 サンプルプログラム
以下のサンプルプログラムは、通常の std::sort と並列コードのソートの実行時間を評価して、並列コードのパフォーマンスと効率を検証します。
namespace parallel_sort_impl { #if defined( _WIN32 ) static HANDLE hStdout = ::GetStdHandle(STD_OUTPUT_HANDLE); const WORD wdRed = FOREGROUND_RED | FOREGROUND_INTENSITY; const WORD wdWhite = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE; #endif void stress_test(void) { while (true) { std::size_t count = 0L; std::vector<std::int64_t> array, array_copy; misc::init(array, std::make_pair(std::pow(10, misc::minval_radix), \ std::pow(10, misc::maxval_radix)), count); array_copy.resize(array.size()); std::copy(array.begin(), array.end(), array_copy.begin()); std::chrono::system_clock::time_point \ time_s = std::chrono::system_clock::now(); std::cout << "sorting an array...\n"; std::sort(array.begin(), array.end(), [](std::int64_t first, std::int64_t end) { return first < end; }); std::chrono::system_clock::time_point \ time_f = std::chrono::system_clock::now(); std::chrono::system_clock::duration \ std_sort_time_elapsed = time_f - time_s; std::cout << std::setiosflags(std::ios::fixed) << std::setprecision(4) << "array size = " << count << " execution time (std::sort): " << std_sort_time_elapsed.count() << " ms "; time_s = std::chrono::system_clock::now(); internal::parallel_sort(array_copy.begin(), array_copy.end(), [](std::int64_t first, std::int64_t end) { return first < end; }); time_f = std::chrono::system_clock::now(); std::size_t position = 0L; std::chrono::system_clock::duration \ qsort_time_elapsed = time_f - time_s; bool is_sorted = misc::sorted(array_copy.begin(), array_copy.end(), position, [](std::int64_t first, std::int64_t end) { return first < end; }); std::double_t time_diff = \ std_sort_time_elapsed.count() - qsort_time_elapsed.count(); #if defined( _WIN32 ) ::SetConsoleTextAttribute(hStdout, \ (is_sorted == true) ? wdWhite : wdRed); #else if (is_sorted == false) std::cout << "\033[1;31m"; #endif std::cout << "<--> (internal::parallel_sort): " << qsort_time_elapsed.count() << " ms " << "\n"; std::cout << "verification: "; if (is_sorted == false) { std::cout << "failed at pos: " << position << "\n"; std::cin.get(); misc::print_out(array_copy.begin() + position, array_copy.end() + position + 10); } else { std::double_t ratio = qsort_time_elapsed.count() / \ (std::double_t)std_sort_time_elapsed.count(); std::cout << std::setiosflags(std::ios::fixed) << std::setprecision(2) << "passed... [ time_diff: " << std::fabs(time_diff) << " ms (" << "ratio: " << (ratio - 1.0) * 100 << "% (" << (1.0f / ratio) << "x faster)) depth = " << internal::g_depth << " ]" << "\n"; } std::cout << "\n"; #if !defined ( _WIN32 ) if (is_sorted == false) std::cout << "\033[0m"; #endif } } void parallel_sort_demo(void) { std::size_t count = 0L; std::cout << "Enter the number of data items N = "; std::cin >> count; std::vector<std::int64_t> array; misc::init(array, std::make_pair(std::pow(10, misc::minval_radix), \ std::pow(10, misc::maxval_radix)), count); std::chrono::system_clock::time_point \ time_s = std::chrono::system_clock::now(); internal::parallel_sort(array.begin(), array.end(), [](std::int64_t first, std::int64_t end) { return first < end; }); std::chrono::system_clock::time_point \ time_f = std::chrono::system_clock::now(); std::size_t position = 0L; std::chrono::system_clock::duration \ qsort_time_elapsed = time_f - time_s; std::cout << "Execution Time: " << qsort_time_elapsed.count() << " ms " << "depth = " << internal::g_depth << " "; bool is_sorted = misc::sorted(array.begin(), array.end(), position, [](std::int64_t first, std::int64_t end) { return first < end; }); std::cout << "(verification: "; if (is_sorted == false) { std::cout << "failed at pos: " << position << "\n"; std::cin.get(); misc::print_out(array.begin() + position, array.end() + position + 10); } else { std::cout << "passed...)" << "\n"; } char option = '\0'; std::cout << "Do you want to output the array [Y/N]?"; std::cin >> option; if (option == 'y' || option == 'Y') misc::print_out(array.begin(), array.end()); } } int main() { std::string logo = "Parallel Sort v.1.00 by Arthur V. Ratz"; std::cout << logo << "\n\n"; char option = '\0'; std::cout << "Do you want to run stress test first [Y/N]?"; std::cin >> option; std::cout << "\n"; if (option == 'y' || option == 'Y') parallel_sort_impl::stress_test(); if (option == 'n' || option == 'N') parallel_sort_impl::parallel_sort_demo(); return 0; } #endif // PARALLEL_STABLE_SORT_STL_CPP
サンプルプログラムを実行すると、ここで紹介した並列ソートは、通常の qsort または std::sort 関数よりもかなに高速 (最大 2 ~ 6 倍) であることが分かります。
添付ファイル | サイズ |
---|---|
parallel-sort-w64.zip | 8.5KB |
parallel-sort-w64-exe.zip | 52.1KB |
製品とパフォーマンス情報
1 インテル® コンパイラーでは、インテル® マイクロプロセッサーに限定されない最適化に関して、他社製マイクロプロセッサー用に同等の最適化を行えないことがあります。これには、インテル® ストリーミング SIMD 拡張命令 2、インテル® ストリーミング SIMD 拡張命令 3、インテル® ストリーミング SIMD 拡張命令 3 補足命令などの最適化が該当します。インテルは、他社製マイクロプロセッサーに関して、いかなる最適化の利用、機能、または効果も保証いたしません。本製品のマイクロプロセッサー依存の最適化は、インテル® マイクロプロセッサーでの使用を前提としています。インテル® マイクロアーキテクチャーに限定されない最適化のなかにも、インテル® マイクロプロセッサー用のものがあります。この注意事項で言及した命令セットの詳細については、該当する製品のユーザー・リファレンス・ガイドを参照してください。
注意事項の改訂 #20110804