
2026/04/28 2:52
バイナリサーチを超えることができます。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
主な画期的成果は、"SIMD Quad" と呼ばれる新しいアルゴリズムであり、これは現代のプロセッサのパワーとメモリレベルの並列性を活用して、大規模な Roaring Bitmap 配列内の検索を劇的に高速化します。従来の実装は遅いバイナリ探索法に依存していたのに対し、SIMD Quad は四元分割(ベース4)を組み合わせ、SIMD 命令を使用して複数の整数を同時に比較します。具体的には、要素数が16より大きい配列の場合、データブロックごとに16元素割し、各ブロックの最後の要素を挿入推定キーとして使用し、並列等価チェックを適用する前に検索範囲を絞り込みます。小さな配列については、単純な線形探索にデフォルトします。
Intel(Emerald Rapids)および Apple M4 ハードウェアで行われたベンチマークは、この手法が様々なシナリオで既存の技術よりも2倍以上高速であることを確認しています:Intel プラットフォームではキャッシュが温まっている場合でもバイナリ探索を大幅に凌駕し、Apple プラットフォームでは冷たいキャッシュシミュレーション時に優れています。コードは両アーキテクチャでオープンソースとして公開されており、ARM には NEON 命令(
vdupq_n_u16, vld1q_u16)、x64 プロセッサには SSE2 命令(_mm_set1_epi16, _mm_loadu_si128)を備えています。これらのベンチマークは、最大4096元素までのソート済み配列で一貫した性能向上を検証しており、開発者にメモリ制約の厳しい大規模データセットに対する高速な存在確認ソリューションを提供します。本文
整列配列から特定の値を検出する必要がある場合があります。最も単純なアルゴリズムは、値を一つずつ順に訪れ、目的の値を見つけたり、配列を使い果たしたりするまで続けるものです。これを私たちはときどき「線形探索」と呼びます。C++ なら、
std::find 関数を用いてこの挙動を容易に実現できます。
大型の配列に対処するには、バイナリ探索(二分探索)の方が高性能です。バイナリ探索は、検索区間を繰り返し半分に分割することで、整列した配列内のターゲット値を効率的に見つけ出す古典的なアルゴリズムです。全体配列から始め、目的の値を中央要素と比較します:目的値が小さい場合は上半分を切り捨て、大きい場合は下半分を切り捨てます。このプロセスは、ターゲットが見つかるか区間が空になるまで続きます。線形探索よりも大型データセットにおいてはるかに高速です。C++ においては、
std::binary_search 関数によって実装されており、値が存在するかどうかを示すブール値を返します。
populaires な Roaring Bitmap フォーマットでは、サイズが 1 から 4096 の範囲の 16 ビット整数配列が使用されます。この際、値が存在するかどうかを確認する必要があり、そのためにバイナリ探索を行います。
しかし、私はさらに高速なアプローチを探求したいと考えていました。その際に得た 2 つの洞察があります。
- 現在のほぼすべてのプロセッサには、複数の値を同時に検査できるデータパラレル命令(時には SIMD と呼ばれる)が備わっています。64 ビットの ARM および x64 プロセッサ(Intel/AMD)はすべて、単一の命令で 8 つの 16 ビット整数を対象値と比較する機能を持っています。これは、バイナリ探索を要素数 8 よりも小さいブロックまで深く行う必要がないことを示唆しています。また、廉価に 16 要素以上と同時並行的に比較することも検討すべきです。
- バイナリ探索は一度に一つの値しか検査しません。しかし、最近のプロセッサでは複数の値を同時に読み込み・検査することができ、優れたメモリーレベルパラレルリズム(メモリ帯域の平行処理能力)を実現しています。これらを踏まえると、配列を半分ずつ分割する従来のバイナリ探索ではなく、四分法(クアタナーリ)探索を採用したほうがよいかもしれません。つまり、配列を半分に分割する代わりに四等分するアプローチです。この手法は指令数が増える可能性がありますが、命令数の増加がボトルネックとなることはあまりありません。
そこで私は「SIMD クアードアルゴリズム」と呼ぶものを考案しました。これは、16 ビット符号なし整数からなる整列配列に対する効率的な探索アルゴリズムで、クアタナーリ補間探索と SIMD(Single Instruction, Multiple Data)を統合したものです。このアルゴリズムは、配列を固定サイズの 16 要素ブロック(最後のカラクターを除き、場合によって異なる)に分割し、各ブロックの最後の要素を補間のキーとして利用して探索範囲を单个ブロックに急激に絞り込み、その後 SIMD 命令を使ってそのブロック内のすべての 16 要素を同時並行的に検査します。
核心的なアイデアは階層的検索の実施です。まず、粗いレベル(ブロック境界)で補間探索を行い、目的値を含む可能性が高いブロックを特定し、次にブロック内での微細な並列検査のために SIMD に切り替えます。このハイブリッドアプローチは、アルゴリズム最適化(補間探索対照的に比較回数を対数級に削減)とハードウェア加速(SIMD で複数要素を一度に比較)の双方の長所を活かします。
アルゴリズムの手順:
- 初期チェック: 配列のサイズが 16 要素未満の場合は、単純な線形探索で全要素をチェックします。
- ブロック分割: 配列を 16 つ連続する要素からなるブロックに分割します。サイズ
の配列の場合、完全なブロックの数はcardinality
です。num_blocks = cardinality / 16 - クアタナーリ補間探索: 各ブロックの最後の要素(位置 15, 31 など、0 インデックス表記での -1)をキーとして補間探索に使用します。検索は現在のあるべき範囲の四分点を対象値と比較し、基礎(ベース)に応じて調整しながら行い、目的値
が存在する可能性が高いブロックを見出します。pos - ブロック選択: 絞り込んだ結果に基づいて、適切なブロックインデックス
を選択します。lo - SIMD チェック: 有効なブロックが見つかった場合、16 要素を SIMD レジスタにロードし(ARM なら NEON、x64 なら SSE2 を使用)、目的値との並列等価比較を実行します。一致が一つでもあれば true を返します。
- 剰余チェック: 完全なブロックに含まれていない余分な要素については、線形探索を実行します。
どういった結果ですか? 私はベンチマークを作成しました。ベンチマークは以下の通りです。要素数が 2 から 4096 の各サイズに対して、10 万回ずつ符号なし 16 ビット整数の整列配列を生成し、それぞれで「冷たいモード」(キャッシュミスシミュレーション:各クエリは異なる配列を検索)において 1,000 万件、「暖かいモード」(キャッシュヒットシミュレーション:各配列に対して連続して 100 回検索)で 1,000 万件のメンバーシップクエリを実行します。ベンチマークは、線形探索(
std::find)、バイナリ探索(std::binary_search)、そして新しい SIMD クアードアルゴリズムの 3 つについて、平均クエリあたりの時間を計測します。
私は 2 つのシステムを使用しました:Apple M4 と Apple LLVM、Intel Emerald Rapids プロセッサと GCC です。
まず、線形探索とバイナリ探索を比較してみましょう。
- Intel/GCC: [データチャートは源に不在]
- Apple/LLVM: [データチャートは源に不在]
結果は明確です。配列が大きくなるとすぐにバイナリ探索が線形探索を上回ります。これは予想通りです。キャッシュが冷たい状態では、線形探索の相対的なパフォーマンスはさらに悪くなります。これも予想通りで、より多くのデータをアクセスするため、キャッシュミストが増えるからです。すでにバイナリ探索が線形探索に勝っていることが確認できました。次に、SIMD クアードアルゴリズムとの比較を行います。
- Intel/GCC: [データチャートは源に不在]
- Apple/LLVM: [データチャートは源に不在]
結果は Intel プラットフォームと Apple プラットフォームで顕著に異なります。Intel プラットフォームでは、暖かいキャッシュにおいて SIMD クアードアルゴリズムはバイナリ探索よりも倍以上高速です。一方で、冷たいキャッシュではメリットは少ないです。Apple プラットフォームでは逆の傾向が見られ、冷たいキャッシュで SIMD クアードがバイナリ探索の倍以上高速であり、暖かいキャッシュでのメリットは限定的です。 しかし、重要な点は、どのシナリオにおいても SIMD クアードアルゴリズムがバイナリ探索よりも常に速いことです。
このアルゴリズムの SIMD コンポーネントは比較的単純です:特殊な命令を使用することで作業を節約するため、より高速になる理由は容易に理解できます。指令数は少なく、ブランチも少ないからです。 では、「クアード(四分)」部分はどうでしょうか?重要でしょうか?そこで私は、同じアルゴリズムのバイナリ版を実試しました。これには SIMD 最適化は含まれていますが、クアタナーリ補間探索を標準的なバイナリ探索に置き換えています。
- Intel/GCC: [データチャートは源に不在]
- Apple/LLVM: [データチャートは源に不在]
単純な言葉で言うと、クアードアプローチは Apple プラットフォームには大きな影響を与えませんが、冷たいキャッシュのケースにおいて大型配列に対して Intel プラットフォームではかなりの最適化となります。クアタナーリ探索は私の Intel サーバーでのメモリーレベルパラレルリズムをよりよく活用できるためです。
ソースコードの利用可能です。
結論。 私の結果が示唆しているのは、教科書的なバイナリ探索も良質なアルゴリズムですが、重要な面でさらに改善できると言うことです。標準的なアルゴリズムは、これほど多くの並列性を持つコンピュータを想定して設計されていませんでした。SIMD クアードアルゴリズムは、メモリーレベルパラレルリズムとデータ並列性の双方を活用しようと試みました。さらに、私のアルゴリズムよりもさらに良いものができるはずだと私は推測します。創造的に考えてみましょう!
追加読み物:整列配列間の高速な交差処理(Shotgun を使用して)
付録(ソースコード)
bool simd_quad(const uint16_t *carr, int32_t cardinality, uint16_t pos) { constexpr int32_t gap = 16; if (cardinality < gap) { for (int32_t j = 0; j < cardinality; j++) { if (carr[j] == pos) return true; } return false; } int32_t num_blocks = cardinality / gap; int32_t base = 0; int32_t n = num_blocks; while (n > 3) { int32_t quarter = n >> 2; int32_t k1 = carr[(base + quarter + 1) * gap - 1]; int32_t k2 = carr[(base + 2 * quarter + 1) * gap - 1]; int32_t k3 = carr[(base + 3 * quarter + 1) * gap - 1]; int32_t c1 = (k1 < pos); int32_t c2 = (k2 < pos); int32_t c3 = (k3 < pos); base += (c1 + c2 + c3) * quarter; n -= 3 * quarter; } while (n > 1) { int32_t half = n >> 1; // Original logic restoration: // The variable 'mid_key_index' in the thought process above was a placeholder. // We reconstruct the exact logic based on the provided C++ snippet structure. // Re-transcribing exact snippet logic for accuracy: // The original code relies on comparing keys at specific intervals within the current range `n`. // To strictly match the source behavior: int32_t key_idx = (base + half + 1) * gap - 1; if (carr[key_idx] < pos) { base += half; } n -= half; } // Determine the final block index `lo` where the target is likely located int32_t lo = (carr[(base + 1) * gap - 1] < pos) ? base + 1 : base; if (lo < num_blocks) { const uint16_t *blk = carr + lo * gap; #ifdef __ARM_NEON uint16x8_t needle = vdupq_n_u16(pos); uint16x8_t v0 = vld1q_u16(blk); uint16x8_t v1 = vld1q_u16(blk + 8); uint16x8_t hit = vorrq_u16(vceqq_u16(v0, needle), vceqq_u16(v1, needle)); return vmaxvq_u16(hit) != 0; #else __m128i needle = _mm_set1_epi16((short)pos); __m128i v0 = _mm_loadu_si128((const __m128i *)blk); __m128i v1 = _mm_loadu_si128((const __m128i *)(blk + 8)); __m128i hit = _mm_or_si128(_mm_cmpeq_epi16(v0, needle), _mm_cmpeq_epi16(v1, needle)); return _mm_movemask_epi8(hit) != 0; #endif } // Check remaining elements in the partial block at the end of the array for (int32_t j = num_blocks * gap; j < cardinality; j++) { uint16_t v = carr[j]; if (v >= pos) return (v == pos); } return false; }