Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Binary search with modern processors

Binary search with modern processors

第16回 StringBeginners での発表資料

7336da77de517e04e2438553e4f8071d?s=128

Shunsuke Kanda

June 12, 2021
Tweet

Transcript

  1. 遅い二分探索はみんな嫌だよね? @kampersanda

  2. 配列 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; }
  3. 配列 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; }
  4. 配列 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; }
  5. 配列 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 ̄
  6. というわけで本日は 現代のプロセッサを意識した二分探索の実装テクの解説 ❏ 方法自体はすごく簡単 ❏ どちらかといえば、その背景や原因を紹介したい ❏ 基本的な問題設定なので応用は利くはず ❏ これからの実装最適化ライフに役立てよう

    内容としては以下の論文の一部を紹介するもの ❏ Khuong & Morin. Array layouts for comparison-based searching. ACM Journal of Experimental Algorithmics (JEA), 2017.
  7. お品書き 条件分岐について 1. プロセッサの分岐予測について 2. なぜ問題なのか? 3. 解決するには? メモリアクセスについて 1.

    キャッシュの仕組みについて 2. なぜ問題なのか? 3. 解決するには? 教科書(3章が神) 簡単なプロセッサの復習もし つつ進めていきます
  8. お品書き 条件分岐について 1. プロセッサの分岐予測について 2. なぜ問題なのか? 3. 解決するには? メモリアクセスについて 1.

    キャッシュの仕組みについて 2. なぜ問題なのか? 3. 解決するには? 教科書(3章が神) 簡単なプロセッサの復習もし つつ進めていきます やっていき!
  9. プロセッサの命令実行の流れ 1.命令の読み込み(Fetch) 2.命令の解釈(Decode) 3.データの取り出し(Operand Fetch) 4.演算(Execution) 5.結果の書き込み(Write Back) メモリ フェッチ

    ユニット デコード ユニット レジスタ ファイル 演算 ユニット ロードストア ユニット ※1例です
  10. プロセッサの命令実行の流れ 1.命令の読み込み(Fetch) 2.命令の解釈(Decode) 3.データの取り出し(Operand Fetch) 4.演算(Execution) 5.結果の書き込み(Write Back) メモリ フェッチ

    ユニット デコード ユニット レジスタ ファイル 演算 ユニット ロードストア ユニット Fetch Decode Operand Fecth Write Back Execution ※1例です
  11. パイプライン実行 暇なユニットができないように次々と命令を処理する フェッチ デコード オペランド メモリ 読み込み ライト バック フェッチ

    デコード オペランド 演算 ライト バック フェッチ デコード オペランド 演算 ライト バック ロード命令 演算命令 演算命令 時間(サイクル)
  12. パイプライン実行 暇なユニットができないように次々と命令を処理する フェッチ デコード オペランド メモリ 読み込み ライト バック フェッチ

    デコード オペランド 演算 ライト バック フェッチ デコード オペランド 演算 ライト バック ロード命令 演算命令 演算命令 何らかの原因(ハザード)で待つ必要も出てくる(ストール) 時間(サイクル)
  13. 制御ハザード 条件分岐によって起こるハザード フェッチ デコード オペランド 演算 ライト バック フェッチ デコード

    オペランド 条件判定 ライト バック フェッチ デコード オペランド 演算命令 条件分岐命令 演算命令 フェッチ デコード ストア命令 演算命令 フェッチ とりあえず 片方の場合を 進めてみる 時間(サイクル)
  14. 制御ハザード 条件分岐によって起こるハザード フェッチ デコード オペランド 演算 ライト バック フェッチ デコード

    オペランド 条件判定 ライト バック フェッチ デコード オペランド 演算命令 条件分岐命令 演算命令 フェッチ デコード ストア命令 演算命令 フェッチ フェッチ 分岐先の命令を 再スタート デコード 分岐先じゃ無かったので 実行中止 (パイプラインフラッシュ) なんとか実行中止を回避できないか? 分岐予測! とりあえず 片方の場合を 進めてみる 時間(サイクル)
  15. 分岐予測 モチベ:現実の条件分岐の結果には大きな偏りがある for (int i = 0; i < 100;

    i++) { // 画期的な処理 } 99回のTrue と 1回のFalse プロセッサは過去の分岐結果を記憶 ❏ 飽和カウンタ、ループ予測器、などなど 次に起こりそうな分岐を予測することで制御ハザードを緩和する! Trueの場合を進めてれば良さそう!
  16. 本題:二分探索での条件分岐は? 改めて見ると予測できそうにない ❏ 中の分岐はたぶん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 しやすい実装にしよう! どうするか?
  17. 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; } 探索エリアの先頭ポインタを更新し、エリアを絞ってるだけ でもこれで制御ハザードが緩和できる!
  18. 実験結果 2倍高速! (論文から引用) ただし n が 小さいことに注意! On Intel 4790K

    with four 8GB DDR3-1866 RAM
  19. もっと大きな n での実験結果 (1/2) n が L3キャッシュサイズを超えた辺り から逆転する なぜか? Branch-freeではPrefetchが走ってな

    いっぽい Branchyでは分岐予測を外しながらも Prefetchが走ってて、その恩恵が大き い(50%くらいは当たる) つまり、予測ミスの損失よりもPrefetch が動作しないことでのキャッシュミスの 損失が大きくなってる (論文から引用) On Intel 4790K with four 8GB DDR3-1866 RAM Prefetchの参考:https://news.mynavi.jp/article/computer_architecture-9/
  20. どうするか? 明示的にPrefetch命令をよぶ! つまり、次のアクセス候補である前後 半の両方のメモリをPrefetchするよう に明記しておく 片方のPrefetchは無駄になるが、そ れでも恩恵の方が大きい 結果 もっと大きな n

    での実験結果 (2/2) (論文から引用) Branch-free + Prefetch しか勝たん! e.g., __builtin_prefetch() On Intel 4790K with four 8GB DDR3-1866 RAM Prefetchの参考:https://news.mynavi.jp/article/computer_architecture-9/
  21. お品書き 条件分岐について 1. プロセッサの分岐予測について 2. なぜ問題なのか? 3. 解決するには? メモリアクセスについて 1.

    キャッシュの仕組みについて 2. なぜ問題なのか? 3. 解決するには? 教科書(3章が神) 簡単なプロセッサの復習もし つつ進めていきます やっていき!
  22. キャッシュメモリの仕組み (1/2) メインメモリやハードディスクへのアクセスは重い プロセッサ メインメモリ データ 100~150 cycles 遅いよ!

  23. キャッシュメモリの仕組み (1/2) メインメモリやハードディスクへのアクセスは重い プロセッサ メインメモリ データ キャッシュメモリ データ 2~3 cycles

    頻繁に使うデータは高速にアクセスできるキャッシュメモリに置いておく 容量は小さいので置けるデータには限りがある 最近アクセスされてない順に追い出す方式が一般的(Latest Recently Used) 速いよ!
  24. キャッシュメモリの仕組み (2/2) データはキャッシュラインという単位で持ち運びする プロセッサ メインメモリ キャッシュメモリ 64B 64B 64B 64B

    64B つまり高速メモリアクセスのためには参照の局所性が大事! ❏ 時間的局所性:参照されたデータが近い将来に再び参照される ❏ 空間的局所性:参照されたデータの周辺のデータも参照される
  25. 本題:二分探索はキャッシュ的にどうか? 絞り込むにつれて空間的局所性は良くなっていく それってうれしいの? 例えば、10には必ずアクセスするのに11には滅多にアクセスしない アクセスされ易い場所ほど参照の局所性が悪いレイアウト! 0 1 2 3 4

    5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 (繰り返し検索する場合)
  26. 着想 二分探索木で考えてみると根に近いほどアクセスされる確率が高い 0 1 2 3 4 5 6 7

    8 9 10 11 12 13 14 7 3 11 5 1 13 9 2 0 6 4 10 8 14 12 100% 50% 25% 12.5%
  27. 着想 二分探索木で考えてみると根に近いほどアクセスされる確率が高い 0 1 2 3 4 5 6 7

    8 9 10 11 12 13 14 7 3 11 5 1 13 9 2 0 6 4 10 8 14 12 7 3 11 1 5 9 13 0 2 4 6 8 10 12 14 100% 50% 25% 12.5% 根から幅優先順に要素を配置 Eytzingerレイアウト!!
  28. Eytzingerレイアウトでの探索 全二分木なので計算で子は辿れる 今居る添字を i とすると ❏ 左の子:i × 2 +

    1 ❏ 右の子:i × 2 + 2 7 3 11 1 5 9 13 0 2 4 6 8 10 12 14 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ×2+1 ×2+2 7 3 11 5 1 13 9 2 0 6 4 10 8 14 12 ×2+1 ×2+2
  29. 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が現れるか
  30. 実験結果 On Intel 4790K with four 8GB DDR3-1866 RAM (論文から引用)

    シンプル二分探索と同じく Branch-freeな実装は大きい配 列で低速 兄弟が同じキャッシュラインに乗 るようにアライメントを考えること で更に高速化 Prefetchを明示的に利かせ ることで高速化
  31. まとめ 配列がL2キャッシュに収まる程度のときは Branch-free 二分探索が高速 L3キャッシュサイズを超える辺りから最適化 Eytzinger レイアウトが基本最速 どちらも普通の二分探索より2倍くらい速くなるので std::lower_bound とかと置き換え

    てみると良いかも(もちろん計算機環境との相談) Branch-freeが遅くなる原因などをサラッと紹介したが、その原因をちゃんと突き詰めて るのがホントにすごくて実験論文として見習わなくてはいけない 実装最適化は楽しいけど、その恩恵が大きいかを見定めて取り掛からないと沼なので 注意しよう(標準ライブラリ最高!) 私見