配列 a[0..n-1] で x を二分探索 一見シンプルでいい感じの二分探索だが、実 は隠れた2つのボトルネック わかりますでしょうか? int bsearch(int* a, int n, int x) { int lo = 0, hi = n; while (lo < hi) { int m = (lo + hi) / 2; if (x < a[m]) hi = m; else if (x > a[m]) lo = m + 1; else return m; } return hi; }
配列 a[0..n-1] で x を二分探索 一見シンプルでいい感じの二分探索だが、実 は隠れた2つのボトルネック わかりますでしょうか? 1. 予測しにくい条件分岐 分岐予測は期待できない int bsearch(int* a, int n, int x) { int lo = 0, hi = n; while (lo < hi) { int m = (lo + hi) / 2; if (x < a[m]) hi = m; else if (x > a[m]) lo = m + 1; else return m; } return hi; }
配列 a[0..n-1] で x を二分探索 一見シンプルでいい感じの二分探索だが、実 は隠れた2つのボトルネック わかりますでしょうか? 1. 予測しにくい条件分岐 2. ランダムなメモリアクセス 分岐予測は期待できない 参照の局所性どうなの? int bsearch(int* a, int n, int x) { int lo = 0, hi = n; while (lo < hi) { int m = (lo + hi) / 2; if (x < a[m]) hi = m; else if (x > a[m]) lo = m + 1; else return m; } return hi; }
配列 a[0..n-1] で x を二分探索 一見シンプルでいい感じの二分探索だが、実 は隠れた2つのボトルネック わかりますでしょうか? 1. 予測しにくい条件分岐 2. ランダムなメモリアクセス 分岐予測は期待できない 参照の局所性どうなの? int bsearch(int* a, int n, int x) { int lo = 0, hi = n; while (lo < hi) { int m = (lo + hi) / 2; if (x < a[m]) hi = m; else if (x > a[m]) lo = m + 1; else return m; } return hi; } _人人人人人人人人人人人人_ > プロセッサと和解せよ <  ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
本題:二分探索での条件分岐は? 改めて見ると予測できそうにない ❏ 中の分岐はたぶん50%の確率で外す ❏ 外のループはいつ終わるかわからん int bsearch(int* a, int n, int x) { int lo = 0, hi = n; while (lo < hi) { int m = (lo + hi) / 2; if (x < a[m]) hi = m; else if (x > a[m]) lo = m + 1; else return m; } return hi; } 予測が要らない or しやすい実装にしよう! どうするか?
Branch-free二分探索 Conditional Move (CMOV) を使って解決 CMOV命令とは? ❏ Trueの場合にだけデータをコピー ❏ Falseでは何もしない ❏ 分岐予測を要求しない! Loopも厳密に⎡log 2 n⎤回 int bsearch_bf(int* a, int n, int x) { int* b = a; while (n > 1) { int m = n / 2; b = (b[m] < x) ? &b[m] : b; n -= m; } return (*b < x) + b - a; } 探索エリアの先頭ポインタを更新し、エリアを絞ってるだけ でもこれで制御ハザードが緩和できる!
Cache-friendly二分探索(Eytzingerレイアウト) int bsearch_cf(int* a, int n, int x) { int i = 0; while (i < n) { if (x < a[i]) i = 2*i + 1; else if (x > a[i]) i = 2*i + 2; else break; } int j = (i+1) >>= __builtin_ffs(~(i+1)); return j == 0 ? n : j-1; } 計算で二分木での探索をシミュレートする アクセスされやすい根付近の要素はキャッシュ に残り続ける(という期待) a[i] に対応する元の配列での添字は、ビット演 算で復元できる(論文参照) シンプル二分探索と同様、Branch-free化や Prefetch明記によって最適化(論文参照) __builtin_ffs: 2進表現で何桁目に初めて 1が現れるか