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

Binary search with modern processors

Binary search with modern processors

第16回 StringBeginners での発表資料

Shunsuke Kanda

June 12, 2021
Tweet

More Decks by Shunsuke Kanda

Other Decks in Programming

Transcript

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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 ̄

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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 しやすい実装にしよう!
    どうするか?

    View Slide

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

    View Slide

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

    View Slide

  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/

    View Slide

  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/

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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%

    View Slide

  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レイアウト!!

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide