3 ポイント 投稿者 GN⁺ 2026-05-01 | 1件のコメント | WhatsAppで共有
  • ソート済み配列で値を探す際によく使われる二分探索は、一度に1つの値しか比較しないが、最新のCPUは1命令で複数の値を同時に比較できるため、この能力を活用すれば探索速度を大きく高められる
  • SIMD Quadは配列を16個ずつのブロックに分け、ブロック位置は4分木的な補間探索で素早く絞り込み、ブロック内部はSIMD命令で16要素をまとめて比較する階層型探索アルゴリズム
  • ベンチマークでは、Intelのwarm cache基準で二分探索比2倍以上高速で、Appleのcold cacheでも2倍以上高速、すべての測定条件でstd::binary_searchより優れていた
  • 二分探索のように半分に分ける代わりに4分割すると、命令数はやや増えるものの、Intelの大規模配列ではメモリレベル並列性をより活用でき、cold cache性能が改善した
  • 教科書的アルゴリズムは、今日のCPUのデータ並列性とメモリ並列性を前提に設計されていないため、ハードウェア特性を踏まえて再設計することで実効性能を向上できる

中核アイデア

  • 二分探索は一度に1つの値だけを比較する構造だが、最新の64ビットARMおよびx64プロセッサは、1命令で16ビット整数8個を同時比較できる
  • このハードウェア能力を活用すれば、探索単位を個々の要素ではなくブロック単位に変え、比較回数を大幅に減らせる
  • 配列を半分に分ける代わりに**4分割(4分探索)**すると、命令数はやや増えても、ボトルネックは命令数ではない可能性が高く、メモリレベル並列性もより活用しやすい

ソート済み配列のメンバーシップ検査の基本方式

  • ソート済み配列で値の存在有無を確認する最も単純な方法は、値を1つずつ調べる線形探索で、C++ではstd::findで同等の処理ができる
  • 大きな配列では二分探索のほうが速く、探索区間の中央の値を基準に上下半分を捨てる処理を繰り返す
  • C++のstd::binary_searchは、値の存在有無をブール値で返す
  • Roaring Bitmap形式は、サイズ1〜4096の16ビット整数配列を使い、値の存在確認に二分探索を用いる

SIMD Quadアルゴリズムの構造

  • 16ビット符号なし整数のソート済み配列を、16要素固定長ブロックに分割
  • 各ブロックの最後の要素を補間キーとして使い、対象値がありそうな単一ブロックまで範囲を絞り込んだうえで、そのブロックの16要素をSIMDで同時に調べる
  • 動作手順:
    • 要素数が16未満なら、全体を単純な線形探索
    • 配列を16個連続要素のブロックに分け、全ブロック数はnum_blocks = cardinality / 16
    • ブロック末尾要素をキーとして使い、現在の探索範囲の1/4地点を対象値と比較してbaseを調整
    • 有効なブロックなら、ARMではNEON、x64ではSSE2で16要素をロードし、並列な等値比較を行う
    • 全ブロックに含まれない残りの要素は線形探索する
    広告

ベンチマーク方法

  • 配列サイズ2〜4096のそれぞれについて、16ビット符号なし整数のソート済み配列を100,000個生成
  • 各サイズごとに、2つのモードでメンバーシップクエリを1,000万回実行
    • coldモード: 各クエリが別の配列を探索し、キャッシュミスをシミュレート
    • warmモード: 同じ配列を100回連続で探索し、キャッシュヒットをシミュレート
  • 測定対象は平均クエリ時間で、比較対象は線形探索(std::find)、二分探索(std::binary_search)、SIMD Quad
  • 測定システムはApple M4(Apple LLVM)とIntel Emerald Rapids(GCC)
広告

測定結果

  • 配列が大きくなると二分探索は線形探索を明確に上回り、cold cacheではデータアクセスが多いため線形探索がより不利
  • Intelプラットフォーム: warm cacheでSIMD Quadは二分探索より2倍以上高速、cold cacheでは利得はやや小さい
  • Appleプラットフォーム: cold cacheでSIMD Quadは2倍以上高速、warm cacheでは利得はより限定的
  • すべての場合でSIMD Quadはstd::binary_searchより高速だった
  • SIMD部分は特殊命令で処理量を減らし、命令数と分岐も少ないため、高速化の理由は明確

4分探索の効果

  • SIMD最適化は維持しつつ、4分の補間探索を二分探索に置き換えたSIMD binary版も比較
  • Appleプラットフォームでは、4分アプローチの効果は小さかった
  • Intelプラットフォームでは、大規模配列のcold cache環境で4分アプローチが有意な最適化になった
  • Intelサーバーでは、4分探索のほうがメモリレベル並列性をより活用できた

実装の要点

  • simd_quad関数は、uint16_t配列、要素数cardinality、探索対象の値posを受け取り、ブール値を返す
  • gapは16固定で、cardinality < gapなら単純なループで探索
  • ブロック探索ループはn > 3の間、1/4、2/4、3/4地点のブロック末尾値を読んで比較し、3つの比較結果の合計でbaseを移動
  • 選択されたブロックは、ARM NEONのvceqq_u16またはx64 SSE2の_mm_cmpeq_epi16で16要素を2組に分けて並列比較する
  • 残り要素区間は、v >= posとなる地点でv == posかどうかを返す

結論

  • 教科書的な二分探索は十分に良いアルゴリズムだが、実際の性能という観点では意味のある形でさらに高速化できる
  • 標準的なアルゴリズムは、今日のコンピュータの高い並列性を前提に設計されていないことが多い
  • SIMD Quadは、メモリレベル並列性データ並列性の両方を活用しようとするアプローチ
  • さらに優れたアルゴリズムもあり得るため、より創造的なアプローチが必要
  • ソースコード
  • shotgunを使ったソート済み配列間の高速な積集合

1件のコメント

 
GN⁺ 2026-05-01
Hacker Newsのコメント
  • Daniel Lemireが言う低レベルなハードウェア最適化とは別に、二分探索やその実装上の変種が最善なのは、データが整列済み/単調であること以外に何も分からない場合だけだ。
    データ分布について事前情報があるなら、その情報を使ってはるかに高速なアルゴリズムを作れる。紙の辞書で人間が純粋な二分探索より速く適当なページの塊へ移動できるのがその例だ。
    数学的には、整列リスト検索は閉ループ制御アルゴリズムで単調関数の逆関数を求めることに近く、コスト関数を作って勾配降下法やその加速版を使うこともできる。より一般には、過度に抽象化された定式化の解法を持ち込むより、解こうとしている特定の問題についての情報をより多く使う方が常に効率的であり、ハードウェアをよりうまく使って得られる定数倍の改善よりも、はるかに大きくスケーラブルな高速化をもたらしうる。

    • 二分探索についてかなり頭をひねったが、この実装より良いものは見つけられなかった: https://github.com/protocolbuffers/protobuf/blob/44025909eb7...
      1. 密なリストかを O(1) で確認
      2. 上限を確認
      3. 反復回数が固定の二分探索
        固定反復回数は分岐予測器に優しく、コアループも対象ハードウェア向けに乗算を避けつつかなりタイトに最適化されている。もっと賢くしようとする試みはループを悪化させ、利益を生まなかった。構造体配列形式でサイズが 12 あり、たいてい N が小さいので難しくもある。
    • treapに関する記事を読んだ記憶がある。木の平衡を取る代わりに重みを使って、ハフマン符号化のように探索深さを調整し、アクセス頻度が異なる場合に平均アクセス時間を減らす方式だった。
      ブックマークしていなかったので、年に2回くらいまた探している。伝説によれば、今でも探しているらしい。
    • その通りだが、要点はデータについてそれ以上分からないことが多いということだ。
      だからデータベースではB木が標準になっている。データは何であってもよく、新しい行を大量に取り込めば特性はいつでも大きく変わりうる。
      勾配降下法のようなやり方で問い合わせを高速化するアルゴリズムは作れるが、B木はすでに非常に高速で、最悪性能や I/O 要求量の予測可能性、範囲スキャン、ソート順巡回、接頭条件のサポートといった利点も多い。
      特定のデータ分布に対してより効率的な問い合わせアルゴリズムは作れるが、重要な性質を失うことが多い。別のアルゴリズムがより近い初期推定を出せても、最後の項目を見つけるのが遅いかもしれないし、平均は速くても最悪性能が悪すぎて使えないこともある。
      紙の辞書でも最初の推定の後はほぼ二分探索を使っており、その初期推定で減らせる段階は数回しかない。適当なページの塊に到達した後は、必要以上に線形にざっと見る方を使っていて、厳密な二分探索の方が速いかもしれないが、ページをめくる時間とのバランスも必要だ。
    • 「解こうとしている特定の問題についての情報をより多く使う方が効率的」というのは、当たり前でありながら深い。すでに持っている情報が多いほど、すでに持っている情報が多いということだ。
    • Fritz Hengleinは高速なソート/グループ化について興味深い研究をしていた。中心となる論文はGeneric Discrimination Sorting and Partitioning Unshared Data in Linear Time[1]のようだ。
      Ed Kmettはそのアイデアを Haskell 向けのdiscrimination[2]ライブラリとして磨き上げ、関連する技術講演[3]も非常に面白い。
      [1]: https://dl.acm.org/doi/epdf/10.1145/1411203.1411220
      [2]: https://hackage.haskell.org/package/discrimination
      [3]: https://www.youtube.com/watch?v=cB8DapKQz-I
  • 「4分探索」は結局、二分探索ループを1段階展開しただけではないのか。項目がある区間を見つけるための比較回数はだいたい同じで、1回に2個ではなく4個ずつ処理しているように見える。ループ展開でも同じ効果が出そうだ。

    • それよりもっと厄介だ。現代のプロセッサは投機実行を行い、比較結果を予測して、間違いだと分かるか内部限界に達するまで片方の分岐を進み続ける。外れたら投機作業を捨てて数サイクルのペナルティを払い、別の開始点からやり直す。
      実質的にプロセッサの観点では、どのループもすでにある程度展開されており、ループ自体の小さなオーバーヘッドだけが残る。二分探索で主なコストになるのは、メモリやキャッシュからデータを持ってくることなので、本当の勝負は後で必要になるデータをできるだけ早く要求して、到着待ちを減らすことだ。
      4分探索のようなアルゴリズムの違いは、各分岐の片側だけを深く先読みする代わりに、ありうるすべてのケースを浅くプリフェッチする点にある。結局必要になるプリフェッチは必ず発行され、実際の実行経路で使わないデータに帯域を少し無駄遣いする程度で済む。
      探索アルゴリズムの実際の性能を予測するうえで、「比較回数」はほとんど役に立たない指標だ。ボトルネックはどれだけ多く比較できるかではなく、メモリとキャッシュ帯域をどれだけ使い切れるかにある。現代プロセッサの分岐動作を内部まで考えれば、ループ展開と見なすこともできる。
    • これは少しループを展開したものと見てもよい。性能が良くなる理由は命令数やメモリ読み出しを大きく減らすからではなく、演算間の依存関係を緩和して純粋な直列実行を避けるからだ。分岐の両側を投機実行するのに近いと見ることもできる。
    • 4分探索は実質的に、現在の反復の比較と同時に、次の反復でありうる2つの比較を一緒に行っている。単純なループ展開よりは少し複雑だ。
      いずれにせよ両方の探索とも定数が異なる O(log N) だ。アルゴリズムの授業では定数はあまり重要ではないが、現実ではかなり効くことがある。
    • ある程度その通りだが、展開された段階の間のデータ依存性も取り除いている。
    • プロセッサは、私たちが素朴に考えるようなやり方では動かないからだ。
  • 最近、指数探索[2]についても記事[1]を書いた。探したい要素群自体が整列済みの状態で、配列に対して繰り返し二分探索する必要があるときに使えるアルゴリズムで、私たちのワークロードでは8倍の高速化が出た。
    [1] https://lalitm.com/post/exponential-search/
    [2] https://en.wikipedia.org/wiki/Exponential_search

    • 指数探索は、連番 ID でリソースをアドレスするREST APIで最後の ID が必要なのに専用エンドポイントがないときに便利だ。
      HEAD /users/1 -> 200 OK
      HEAD /users/2 -> 200 OK
      HEAD /users/4 -> 200 OK
      ...
      HEAD /users/2048 -> 200 OK
      HEAD /users/4096 -> 404 Not Found
      それから 2048 と 4096 の間を二分探索すれば、最新ユーザーと副次的にユーザー数を見つけられる。競合 SaaS 企業を調べるときに知っておくと便利な情報だ。
  • CPU は常にキャッシュライン全体、通常は64バイトを一度にアクセスするのだから、キャッシュライン全体を探索する方がよさそうだ。データが CPU 内に載った後は、実質的に無料に近い。
    だから「二分」探索で中央のキャッシュラインのすべての値を調べ、一致する値がなければ左/右へ進む方式を試してみたい。キャッシュライン探索は 512 ビット SIMD 命令1つでできる。64バイトのキャッシュラインは 16 ビット整数 32 個なので、単純な二分探索よりほぼ32倍速いかもしれないし、少なくともメモリアクセスは32倍減って、現実的なプログラムの大半ではそこが支配的だろう。

    • 二分探索木、つまり整列ベクタの上位キャッシュラインで目標値を見つけられる可能性は低い。代わりにライン内の追加データを使って探索範囲を縮める方がよく、そうするとB木や B+木につながる。
      4バイトキーと4バイト子ポインタ、あるいは配列インデックスを使えば、内部ノードはキー7個、子ポインタ8個、次ポインタ1個で64バイトのキャッシュラインをちょうど埋められる。100万項目なら木の深さは約20から約7に減り、上位数段はキャッシュに残っている可能性が高い。
      少し工夫すれば SIMD で B木ノード内探索を高速化できるが、データ依存性が非常に強い。
  • より小さな配列なら、末尾に番兵値を置いた線形探索はすでに打ち負かしにくい。ただ、この主張が曖昧なのは、「小さい」があまりにぼんやりした条件で実感しづらいことにある。

    • それは事実ではない。この記事の素晴らしいベンチマークを見ると、線形探索はだいたい 200〜400 要素あたりで後れを取り始める。
      全体としてこの記事は気に入った。以前から気になっていた点を、有用な除去実験まで添えて完璧に掘り下げている。
    • この記事が扱っているのはそれではない。
  • 小さな配列、16〜32 項目で探索実験を少しやってみたが、二分探索は分岐が多くて最悪の方法の一つだった。小さな配列で最も速かったのは分岐なし線形探索だった。
    ループを途中で止めず、すべての要素をなめる方式だ。たとえば配列にある数が含まれているか知りたければ、全項目の検査結果を論理 OR でまとめる。SIMD は使わなかったが、小さな配列では分岐が非常に高コストなので、分岐なしですべての要素をただ調べる方が速い。

    • プリフェッチャが好むアクセスパターンだから速いのか気になる。
  • アルゴリズムの説明が少し分かりにくかった。
    SIMD 部分は最後の段階、つまり最後の16要素を探索するときにだけ使われる。
    4分探索の部分は3つの地点を調べて4つの経路を作るものだが、単に正しいキーではなく正しいブロックを見つけている。
    細部が興味深くて、著者は各ブロックの最後の要素を4分探索に使っている。最初の要素や任意の要素を使うとアルゴリズムがどう変わるのか気になる。

  • ここでの核心的な直感であるSIMD の多重比較は、最後の段階より大きなスケールにも使えそうだ。
    概要はこうだ: a) gather で等間隔の16か所から複数要素を取ってくる b) SIMD 命令で並列比較 c) 正しいブロックに絞る d) ブロックが小さければ線形探索に切り替え、そうでなければ gather/比較サイクルを繰り返す。
    gather 命令は不連続メモリから読み込み、二分探索なら不要なほど多く読むことになる。それでも多方向比較が可能になって、二分探索4段分を折りたためるなら、大きなテーブルでは勝てるかもしれない。
    すべてのデータ型で完全な16方向比較ができるわけでもない。float64 の探索は AVX-512 でも8方向比較に制限されるが、int32 と float32 は16方向比較が可能だ。

    • gather は極端に遅い。効率を狙う人なら gather を避けるだろう。
      gather ベースの方式より二分探索の方が実際には速い可能性が高いと思う。
  • 古典的な計算機科学のアルゴリズムは、実質的に並列性のない CPU を対象に「設計」されたようなものだ。複数コアも Hyper-threading も SIMD 命令もなく、すべてのメモリアクセス時間が同じで L1/L2/L3 キャッシュ遅延のような概念もなく、一般的/ランダムなデータを扱うという前提だ。
    この前提のどれか一つでも外れると、性能を上げるための調整余地がたくさん生まれる。
    古典アルゴリズムは、特定のデータ形状や特定 CPU の特性/機能をより深く理解したあとで、より最適化された解法を開発するための非常に良い出発点を与えてくれる。
    最適化の尖った先端まで行くと、たいていはデータがメモリにどう保存されどうアクセスされるか、そしてそれを改善する変更が後続段階を損なわないかを見ることになる。昔の職場で、誰かがコードのある部分を長時間最適化したのだが、その最適化のせいで後で必要な大量の情報がキャッシュから追い出され、アプリケーション全体はむしろ遅くなったことがあった。
    これはおそらく Rob Pike のプログラミング第5法則、言い換えれば The Mythical Man Month で Fred Brooks が述べたことの焼き直しに近い。参照: https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.htm...

  • 10代の頃、週末を丸ごと使って、二分探索が各段階で探索空間を半分にするから良いのなら、三分探索は3分の1ずつ減らすのでもっと良いのではないかと考えていた。
    中央値1つだけを比較する代わりに、1/3 地点の値を比較し、低すぎれば 2/3 地点の値を比較する方式だ。
    しかし各段階で二分探索の 1/2 ではなく 1/3 に縮める一方で、比較は平均 3/2 倍すると考えたので、結局同じだと思っていた。
    修正: zamadatix の返信を見ると、実際には 2/3 の場合に比較を2回するので平均 5/3 倍だった。

    • この三分アプローチでは、実際には段階あたりの平均比較回数は 3/2 ではない。
      最初の 1/3: 比較1回
      2番目の 1/3: 比較2回
      3番目の 1/3: 比較2回
      (1+2+2)/3 = 平均 5/3 回の比較だ。「比較を1回するか2回するか」なので50%だと勘違いしやすいが、実際には最初の比較で当たる確率は 1/3、2回比較する確率は 2/3 だ。
      したがって、総平均比較回数では三分探索がごくわずかに悪いことを示せる: 5/3*Log_3[n] = 1.052... * Log_2[n]
      つまり段階数は減るが、最後まで進むには平均してより多く比較することになる。これはこの種のあらゆる探索、つまり分割数が 2 より大きい場合に概ね成り立つ。もちろん、探す値が一様分布し、演算コストが理想化されているなどの前提があり、まさにそこに本文の議論が入ってくる。
    • 10代の頃のその考えにも、実は何かしらの筋はあった。
      二分探索アルゴリズムの三分版としてではない。というのも、それは本当の三分探索ではなく、偏った二分探索に近いからだ。比較は本質的に二値なので、比較を含む探索アルゴリズムはすべて一種の二分探索であり、中央要素以外を選ぶのはアルゴリズム複雑性の観点では効率が落ちる。ただし現実のハードウェアでは、条件次第でより良いこともありうる。本当の三分探索をやるなら、基本演算として3方向比較が必要だ。
      面白くなるのは「基数効率」[1]を考えるときだ。最良の選択は e に最も近い自然数である 3 になる。木探索とも関係があり、三分木が二分木より良いこともある。
      [1] https://en.wikipedia.org/wiki/Optimal_radix_choice
    • 高速探索ができないディスクのような場所では、たとえば128分岐 B木が使える。キー128個を取ってくるコストは1個を取ってくるのと比べて大して高くないが、追加で7回の取り出しを減らせる。
    • その次は、三分比較器を含む CPU を想像したのか。
    • 10代の頃以降、CPU は実行幅とベクトル機能の両方で劇的に広くなった。スループットが増えたことで、依存関係の連鎖を減らすために演算を多めに使う方向へバランスが移っている。
      だから、そのアイデアは当時の CPU では現実的でなかったとしても、今の CPU ではより可能性が高くなっているかもしれない。