はじめに
これまで下記の記事で GNU C ライブラリの qsort
について勉強してきた。
- GNU C Libraryのqsortはクイックソートではないの? - Qiita
- 最強コンパイラを使えばマージソートは遅くない - Qiita
- マージソートを非再帰型に書き直す~誰でも思いつくアイディアだが~ - Qiita
GNU C ライブラリの qsort
はマージソートであり,再帰関数(トップダウン型)として作られていることから,非再帰型(ボトムアップ型)に書き直すことでパフォーマンスアップを狙った。しかし,ボトムアップ型にはまだまだ課題がある。
ボトムアップ型の課題その2
ボトムアップ型の課題といっても実装によるが,配列の先頭からマージを行う場合,配列の末尾に未結合の要素が残ってしまう。とくに配列の要素数が2のべき乗+1の場合,末端の要素がマージされるのは最後になる。最後にマージする二つの区間の要素数がバランスを欠いており,後半部の要素数が1つしかない。
ここで GNU C ライブラリの qsort
では前後二つに分割した配列のうち,後半部の要素数を多くした方が得になることを思い出して欲しい。
また,配列
a[]
を前半部と後半部に分割する際,配列の要素数が奇数だと等分割できない。非均等に分割するのであれば,後半部の要素数を多くした方が得だと分かるだろう。これは後半部の要素数のほうが多いと,後半部の要素が残ってしまう確率が高くなるが,その場合,残った要素を転送しなくても済むからだ。実際,配列の要素数len
が奇数の場合,前半部の要素数n1
よりも後半部の要素数n2
がのほうが一つだけ多くなるよう分割される。
前半部の要素数を少なくした方が効率良いのであれば,以下に示すように配列の先頭に未結合の要素を置いたほうが良いだろう。
改善案
配列の全要素数 $n$,領域の大きさ $s$ とおくと,
- $n \bmod 2s \le s$ のとき,先頭から $n \bmod 2s$ 個の要素は未結合のまま残される。
- $n \bmod 2s > s$ のとき,先頭から $n \bmod 2s$ 個の要素も余さず結合され,前半部が $n \bmod 2s -s$ 個,後半部が $s$ 個となる。
のように場合分けが必要となる。$2s$ で割った余り(剰余演算)を避けるのであれば,初期値 $i = n - 2s$ として $i$ 番目の要素から $s$ 個,$i + s$ 番目の要素から $s$ 個の要素をマージしていき,$i$ は $2s$ ずつ減らしていけばよい。最後の $i \ge 0$ が $n$ を $2s$ で割った余りである。
とりあえずこれをバックワード型と呼ぶことにしよう。
しかし,領域の大きさ $s$ は2のべき乗数なので,$2s$ で割った余りを求める剰余演算は単に $2s - 1$ との論理積(AND)を求めるだけでよい。したがって $i = n \bmod 2s$ として,$i$ を $2s$ ずつ増やしていく方法も考えられる。これをフォワード型と呼ぶことにする。
なぜ,いきなりバックワード型とフォワード型の二種類を作成したのかというと,まず最初にバックワード型を作ったとき,なんとなくキャッシュメモリのヒット率が悪くなるような予感がしたからだ。
一般にメモリアクセス,とくにリード(読出し)動作はランダクアクセスよりもシーケンシャルアクセスのほうが高速だが,それはキャッシュメモリにヒットする確率が高くなるからだ。ただし,シーケンシャルアクセスといっても参照方向が正順と逆順で大差ないのか,あるいは差が生じるのかについては興味深いテーマであろう。
※筆者はフォワード型のほうが速いと思っている。
実装コード
実装コードを以下に示す。
ボトムアップ型マージソート関数その2(バックワード型)
/* 001 */ #include <stdlib.h>
/* 002 */ typedef int (*COMPARE)(int, int);
/* 003 */ #define TEMP_SIZE 256
/* 004 */ static void swap(int *p, int *q) {
/* 005 */ int temp = *p;
/* 006 */ *p = *q;
/* 007 */ *q = temp;
/* 008 */ }
/* 009 */ static void merge(int *buf, int *a, int n1, int n2, COMPARE compare) {
/* 010 */ int n = n1 + n2;
/* 011 */ int *p1 = a;
/* 012 */ int *p2 = a + n1;
/* 013 */ int *q = buf;
/* 014 */ for(;;) {
/* 015 */ if(compare(*p1, *p2) <= 0) {
/* 016 */ *q++ = *p1++; if(--n1 == 0) break;
/* 017 */ } else {
/* 018 */ *q++ = *p2++; if(--n2 == 0) break;
/* 019 */ }
/* 020 */ }
/* 021 */ while(n1 > 0) {
/* 022 */ *q++ = *p1++; n1--;
/* 023 */ }
/* 024 */ for(int i = 0; i < n - n2; i++)
/* 025 */ a[i] = buf[i];
/* 026 */ }
/* 027 */ static void sort(int *buf, int *a, int len, COMPARE compare) {
/* 028 */ for(int i = len - 2; i >= 0; i -= 2)
/* 029 */ if(compare(a[i], a[i + 1]) > 0) swap(&a[i], &a[i + 1]);
/* 030 */ for(int s = 2; s < len; s = s * 2) {
/* 031 */ int i, adj;
/* 032 */ for(i = len - s * 2; i >= 0; i -= s * 2)
/* 033 */ merge(buf, &a[i], s, s, compare);
/* 034 */ if((adj = i + s) > 0)
/* 035 */ merge(buf, a, adj, s, compare);
/* 036 */ }
/* 037 */ }
/* 038 */ void msort2(int *a, int len, COMPARE compare) {
/* 039 */ if(len < 2) return;
/* 040 */ if(len <= TEMP_SIZE) {
/* 041 */ int tmp[TEMP_SIZE];
/* 042 */ sort(tmp, a, len, compare);
/* 043 */ } else {
/* 044 */ int *buf = (int*)malloc(sizeof(int) * len);
/* 045 */ if(buf == (int*)NULL) return;
/* 046 */ sort(buf, a, len, compare);
/* 047 */ free(buf);
/* 048 */ }
/* 049 */ }
ボトムアップ型マージソート関数その3(フォワード型)
/* 001 */ #include <stdlib.h>
/* 002 */ typedef int (*COMPARE)(int, int);
/* 003 */ #define TEMP_SIZE 256
/* 004 */ static void swap(int *p, int *q) {
/* 005 */ int temp = *p;
/* 006 */ *p = *q;
/* 007 */ *q = temp;
/* 008 */ }
/* 009 */ static void merge(int *buf, int *a, int n1, int n2, COMPARE compare) {
/* 010 */ int n = n1 + n2;
/* 011 */ int *p1 = a;
/* 012 */ int *p2 = a + n1;
/* 013 */ int *q = buf;
/* 014 */ for(;;) {
/* 015 */ if(compare(*p1, *p2) <= 0) {
/* 016 */ *q++ = *p1++; if(--n1 == 0) break;
/* 017 */ } else {
/* 018 */ *q++ = *p2++; if(--n2 == 0) break;
/* 019 */ }
/* 020 */ }
/* 021 */ while(n1 > 0) {
/* 022 */ *q++ = *p1++; n1--;
/* 023 */ }
/* 024 */ for(int i = 0; i < n - n2; i++)
/* 025 */ a[i] = buf[i];
/* 026 */ }
/* 027 */ static void sort(int *buf, int *a, int len, COMPARE compare) {
/* 028 */ for(int i = len & 1; i + 1 < len; i += 2)
/* 029 */ if(compare(a[i], a[i + 1]) > 0) swap(&a[i], &a[i + 1]);
/* 030 */ for(int s = 2; s < len; s = s * 2) {
/* 031 */ int i = len & (s * 2 - 1);
/* 032 */ int adj;
/* 033 */ if((adj = i - s) > 0)
/* 034 */ merge(buf, a, adj, s, compare);
/* 035 */ for(; i + s < len; i += s * 2)
/* 036 */ merge(buf, &a[i], s, s, compare);
/* 037 */ }
/* 038 */ }
/* 039 */ void msort3(int *a, int len, COMPARE compare) {
/* 040 */ if(len < 2) return;
/* 041 */ if(len <= TEMP_SIZE) {
/* 042 */ int tmp[TEMP_SIZE];
/* 043 */ sort(tmp, a, len, compare);
/* 044 */ } else {
/* 045 */ int *buf = (int*)malloc(sizeof(int) * len);
/* 046 */ if(buf == (int*)NULL) return;
/* 047 */ sort(buf, a, len, compare);
/* 048 */ free(buf);
/* 049 */ }
/* 050 */ }
試験条件
- 要素数 200,000,000 個のint 型の配列の昇順ソートを行う。マージソートの場合,同サイズの配列がもう一つ必要になることから,32bit プログラムではほぼ上限値である。
4×200,000,000 bytes = 0x2FAF0800 bytes - データの順序は以下の四種類とする。
- ゼロ
- 昇順:0 → 199,999,999
- 降順:199,999,999 → 0
- 乱数:0~199,999,999 の範囲の一様乱数,乱数発生器は XorShift とした3
- Cコンパイラは下記の三種類とし,かつターゲットを 32bit (x86)と64bit (x64)の二種類の組み合わせで合計六種類とする。
- Microsoft Visual C++ 2022
- LLVM clang 17.0.3
- intel C++ compiler:oneAPI 2024.2, Compiler Release 2024.2.1
- 評価マシンのスペックは下記の通り。
Windows10 2022H2,Core i7-13700 5.1GHZ,32GB - 評価するソート関数を以下に示す。
- uCRT:クイックソート,uCRT 版
qsort
の意訳版 - glibc:再帰型(トップダウン型)マージソート,GNU C ライブラリの
qsort
の意訳版 - merge:非再帰型(ボトムアップ型)マージソート,
未結合要素は末尾 - merge2:非再帰型(ボトムアップ型)マージソート,
未結合要素は先頭(バックワード型)※今回作成 - merge3:非再帰型(ボトムアップ型)マージソート,
未結合要素は先頭(フォワード型)※今回作成
- uCRT:クイックソート,uCRT 版
実行時間の比較
実行時間の比較を示す。
コンパイラ | 順序 | uCRT | glibc | merge | merge2 | merge3 |
---|---|---|---|---|---|---|
Visual C++ 32bit版 |
ゼロ | 0.090 | 2.660 | 2.835 | 2.841 | 2.264 |
昇順 | 1.433 | 2.656 | 2.848 | 2.851 | 2.267 | |
降順 | 1.475 | 3.914 | 4.668 | 4.862 | 3.829 | |
乱数 | 15.505 | 17.996 | 19.431 | 19.789 | 17.524 | |
Visual C++ 64bit版 |
ゼロ | 0.093 | 2.470 | 2.158 | 2.123 | 2.086 |
昇順 | 1.582 | 2.473 | 2.148 | 2.126 | 2.087 | |
降順 | 1.617 | 3.769 | 3.370 | 3.517 | 3.432 | |
乱数 | 15.500 | 21.129 | 20.050 | 20.192 | 20.114 | |
LLVM clang-cl 32bit版 |
ゼロ | 0.098 | 2.066 | 2.095 | 2.018 | 1.963 |
昇順 | 1.627 | 2.060 | 2.109 | 2.049 | 1.974 | |
降順 | 1.688 | 3.642 | 3.015 | 3.278 | 3.063 | |
乱数 | 15.636 | 16.487 | 15.981 | 16.199 | 15.876 | |
LLVM clang-cl 64bit版 |
ゼロ | 0.089 | 1.958 | 1.679 | 1.547 | 1.492 |
昇順 | 1.460 | 1.997 | 1.663 | 1.563 | 1.488 | |
降順 | 1.523 | 2.942 | 2.456 | 2.626 | 2.512 | |
乱数 | 15.710 | 16.022 | 15.853 | 15.922 | 15.539 | |
intel icx-cl 32bit版 |
ゼロ | 0.070 | 2.488 | 2.038 | 1.979 | 1.945 |
昇順 | 1.592 | 2.498 | 2.050 | 1.989 | 1.954 | |
降順 | 1.691 | 3.333 | 2.877 | 2.964 | 2.871 | |
乱数 | 17.120 | 17.809 | 17.767 | 17.107 | 17.582 | |
intel icx-cl 32bit版 |
ゼロ | 0.064 | 1.811 | 1.621 | 1.582 | 1.542 |
昇順 | 1.366 | 1.797 | 1.608 | 1.583 | 1.550 | |
降順 | 1.491 | 2.507 | 2.418 | 2.526 | 2.428 | |
乱数 | 16.584 | 15.969 | 15.682 | 15.497 | 15.673 |
こういうのはグラフにすると分かり易い。
■uCRT,■glibc,■merge,■merge2,■merge3
結論
あれこれ工夫しても大して変わらんな・・・