Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

配列 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; }

Slide 3

Slide 3 text

配列 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; }

Slide 4

Slide 4 text

配列 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; }

Slide 5

Slide 5 text

配列 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 ̄

Slide 6

Slide 6 text

というわけで本日は 現代のプロセッサを意識した二分探索の実装テクの解説 ❏ 方法自体はすごく簡単 ❏ どちらかといえば、その背景や原因を紹介したい ❏ 基本的な問題設定なので応用は利くはず ❏ これからの実装最適化ライフに役立てよう 内容としては以下の論文の一部を紹介するもの ❏ Khuong & Morin. Array layouts for comparison-based searching. ACM Journal of Experimental Algorithmics (JEA), 2017.

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

パイプライン実行 暇なユニットができないように次々と命令を処理する フェッチ デコード オペランド メモリ 読み込み ライト バック フェッチ デコード オペランド 演算 ライト バック フェッチ デコード オペランド 演算 ライト バック ロード命令 演算命令 演算命令 何らかの原因(ハザード)で待つ必要も出てくる(ストール) 時間(サイクル)

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

制御ハザード 条件分岐によって起こるハザード フェッチ デコード オペランド 演算 ライト バック フェッチ デコード オペランド 条件判定 ライト バック フェッチ デコード オペランド 演算命令 条件分岐命令 演算命令 フェッチ デコード ストア命令 演算命令 フェッチ フェッチ 分岐先の命令を 再スタート デコード 分岐先じゃ無かったので 実行中止 (パイプラインフラッシュ) なんとか実行中止を回避できないか? 分岐予測! とりあえず 片方の場合を 進めてみる 時間(サイクル)

Slide 15

Slide 15 text

分岐予測 モチベ:現実の条件分岐の結果には大きな偏りがある for (int i = 0; i < 100; i++) { // 画期的な処理 } 99回のTrue と 1回のFalse プロセッサは過去の分岐結果を記憶 ❏ 飽和カウンタ、ループ予測器、などなど 次に起こりそうな分岐を予測することで制御ハザードを緩和する! Trueの場合を進めてれば良さそう!

Slide 16

Slide 16 text

本題:二分探索での条件分岐は? 改めて見ると予測できそうにない ❏ 中の分岐はたぶん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 しやすい実装にしよう! どうするか?

Slide 17

Slide 17 text

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; } 探索エリアの先頭ポインタを更新し、エリアを絞ってるだけ でもこれで制御ハザードが緩和できる!

Slide 18

Slide 18 text

実験結果 2倍高速! (論文から引用) ただし n が 小さいことに注意! On Intel 4790K with four 8GB DDR3-1866 RAM

Slide 19

Slide 19 text

もっと大きな 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/

Slide 20

Slide 20 text

どうするか? 明示的に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/

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

キャッシュメモリの仕組み (1/2) メインメモリやハードディスクへのアクセスは重い プロセッサ メインメモリ データ キャッシュメモリ データ 2~3 cycles 頻繁に使うデータは高速にアクセスできるキャッシュメモリに置いておく 容量は小さいので置けるデータには限りがある 最近アクセスされてない順に追い出す方式が一般的(Latest Recently Used) 速いよ!

Slide 24

Slide 24 text

キャッシュメモリの仕組み (2/2) データはキャッシュラインという単位で持ち運びする プロセッサ メインメモリ キャッシュメモリ 64B 64B 64B 64B 64B つまり高速メモリアクセスのためには参照の局所性が大事! ❏ 時間的局所性:参照されたデータが近い将来に再び参照される ❏ 空間的局所性:参照されたデータの周辺のデータも参照される

Slide 25

Slide 25 text

本題:二分探索はキャッシュ的にどうか? 絞り込むにつれて空間的局所性は良くなっていく それってうれしいの? 例えば、10には必ずアクセスするのに11には滅多にアクセスしない アクセスされ易い場所ほど参照の局所性が悪いレイアウト! 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 (繰り返し検索する場合)

Slide 26

Slide 26 text

着想 二分探索木で考えてみると根に近いほどアクセスされる確率が高い 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%

Slide 27

Slide 27 text

着想 二分探索木で考えてみると根に近いほどアクセスされる確率が高い 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レイアウト!!

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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が現れるか

Slide 30

Slide 30 text

実験結果 On Intel 4790K with four 8GB DDR3-1866 RAM (論文から引用) シンプル二分探索と同じく Branch-freeな実装は大きい配 列で低速 兄弟が同じキャッシュラインに乗 るようにアライメントを考えること で更に高速化 Prefetchを明示的に利かせ ることで高速化

Slide 31

Slide 31 text

まとめ 配列がL2キャッシュに収まる程度のときは Branch-free 二分探索が高速 L3キャッシュサイズを超える辺りから最適化 Eytzinger レイアウトが基本最速 どちらも普通の二分探索より2倍くらい速くなるので std::lower_bound とかと置き換え てみると良いかも(もちろん計算機環境との相談) Branch-freeが遅くなる原因などをサラッと紹介したが、その原因をちゃんと突き詰めて るのがホントにすごくて実験論文として見習わなくてはいけない 実装最適化は楽しいけど、その恩恵が大きいかを見定めて取り掛からないと沼なので 注意しよう(標準ライブラリ最高!) 私見