OpenSearchCon Europe 2026のセッション「Boosting OpenSearch Performance: Lucene Bulk Collection and gRPC Search in Action」をまとめます。
- スピーカー
- Lucene bulk collectionが解くアグリゲーションの性能課題
- JVMのメソッドインライン化と仮想ディスパッチを理解する
- long valuesとcollect rangeによるバルク化の実装
- gRPCトランスポートとREST/JSONの違い
- 検索クエリへ広がったgRPCの対応範囲
- まとめ
- Q&A
スピーカー
このセッションは、NetApp Instaclustr のお二人によって行われました。前半のLucene bulk collection APIについては Abdul Muneer Kolarkunnu 氏が解説しています。同氏はNetApp Instaclustrのオープンソース開発者で、OpenSearchのML Commonsリポジトリのメンテナーを務めるほか、OpenJDKのコミッターでもあります。
後半のgRPCについては Carlos Rolo 氏が担当しました。同氏もNetApp Instaclustrのオープンソースエンジニアで、OpenSearch Ambassadorに就任したばかりです。ML CommonsへのコントリビューションやApache Cassandraでの経験を背景に、OpenSearchへの取り組みを進めています。
Lucene bulk collectionが解くアグリゲーションの性能課題

Apache Lucene に加えられた性能改善が OpenSearch 側にも取り込まれ、アグリゲーション処理が速くなりました。前半のテーマはこの Lucene bulk collection API です。改善が OpenSearch 3.4 で利用可能になったのを受けて、なぜ速くなるのか、その背後で Lucene が何をしているのか、さらに JVM や Java のレイヤーで何が起きているのかを調べた内容を共有します。

アグリゲーションは本来 CPU 負荷の高い処理です。従来の OpenSearch ではドキュメントごとに収集処理(per-doc collection)を行い、それを大量のドキュメントに対して繰り返していました。文書数が増えるほどこの繰り返しがコストとして積み上がります。
Lucene が採った最適化の考え方は、1回の呼び出しでより多くの仕事をこなす、というものです。処理対象のドキュメントをまとめて取得し、一括して計算する。メソッド呼び出し単位で作業をまとめることで、呼び出しの繰り返しによるオーバーヘッドを減らします。サブアグリゲーションがあるとコストはさらに掛け算的に膨らむため、この一括化の効果は大きくなります。bulk collection の実装によって、最大で約40%の性能改善が得られています。
前提としてアグリゲーションには種類があります。average や sum、stats のような数値計算を行う metric aggregation。ドキュメントを特定のバケットに振り分けて集計する bucket aggregation で、histogram や date histogram がこれにあたります。そして一連のアグリゲーションを連結する pipeline aggregation があり、あるアグリゲーションの出力が次の入力になります。
JVMのメソッドインライン化と仮想ディスパッチを理解する

JVMがメソッドインライン化を実際にどう判断するかは、-XX:+PrintInlining(-XX:+UnlockDiagnosticVMOptions と併用)で確認できます。これはどのメソッドがインライン化されたかをコンパイル時に出力するJVMの診断オプションです。
デモでは、getX / setX のような単純なアクセサを呼び出すループを用意し、まず1,000回の繰り返しで実行しました。この回数では出力に getX や setX のインライン化は現れません。JVMのC1/C2コンパイラがそのコードをホットパス(頻繁に通る経路)と判断するには、1,000回程度では足りないからです。
繰り返しを約100万回(スライドでは「10 lakh」)まで増やすと、getX と setX がインライン化されたとログに表示されます。アグリゲーションのようにデータ量が大きい処理であれば、この種のインライン化が自然に効いてくるわけです。ただしインライン化が働くには、対象のコードが同じ実行パス上に並んでいる必要があります。
仮想ディスパッチがインライン化を妨げる

インライン化を阻む典型的な要因が仮想メソッドの呼び出し(virtual dispatch)です。基底クラス Animal を継承した Dog と Cat を用意し、それぞれが speak() をオーバーライドして異なる鳴き声を返す例で説明されました。
List<Animal> zoo に Dog、Cat、Animal を混在させて約100万件追加し、for (Animal a : zoo) { a.speak(); } のように1件ずつ呼び出します。このとき各呼び出しで、JVMは実体がどの型かを実行時に判定してから対応する speak() を選ぶ必要があります。混在したコレクションに対するこの判定はJVMにとって重い処理になり、呼び出し先が一意に定まらないためインライン化も効きません。
対策は、型ごとにリストを分けてしまうことです。Dog だけ、Cat だけ、Animal だけのコレクションをそれぞれ別のループで処理すれば、呼び出し先の型が確定するため仮想ディスパッチが直接呼び出しに変わり、インライン化の対象になります。

型を混在させた1件ずつの呼び出しと、型ごとにまとめた呼び出しの差は、speak() の中に実際の演算を入れて約100万オブジェクトで計測すると数値に表れます。per-object invocation(仮想メソッド経由の個別呼び出し)は1呼び出しあたり3.73ミリ秒かかったのに対し、bulk group invocation(型ごとにまとめた呼び出し)は1.28ミリ秒で済みました。
この差は小規模な実行でも明確に出ます。同じ原理をアグリゲーションに当てはめたものが、Luceneのチームが実装した最適化です。型ごとにまとめて処理することで仮想ディスパッチを減らし、JITによるインライン化を効かせる。この発想がbulk collection APIの性能改善の土台になっています。
long valuesとcollect rangeによるバルク化の実装

前述したJVMの仮想ディスパッチとインライン化の理屈を、Lucene側ではどう実コードに落とし込むかを考える出発点になるのが、型ごとにグルーピングしてから一括処理する書き方です。zooに混在するAnimalを一度走査して、DogはList<Dog>、CatはList<Cat>へinstanceofで振り分けます。仕分けが終わったあとは、dogs.forEach(Dog::speak)のように同じ型だけのリストをタイトなループで回します。
このループでは要素がすべてDogだと確定しているため、speak()の呼び出しは仮想ディスパッチが減り、JITがインライン化を効かせやすくなります。Luceneのbulk collectionも本質的にはこの考え方を踏襲しています。

Luceneに加えられた1つ目の変更が、doc-valuesの一括取得です。従来はドキュメントごとにadvanceExactで位置を合わせ、続けてlongValueを呼ぶという2つの仮想メソッド呼び出しをドキュメント数だけ繰り返していました。
これを置き換えるのが新しいlongValuesAPIです。values.longValues(count, docBuffer, valueBuffer, missingSentinel)という形で、ドキュメントのバッファをまとめて渡し、1回の呼び出しで値を取り出します。仮想メソッドの総数そのものが減るわけではありませんが、呼び出しをまとめることで、同じドキュメント数に対するAPI呼び出しの回数が減り、配列をまたいだ処理でキャッシュ局所性が良くなり、ドキュメント1件あたりのオーバーヘッドが下がります。

新しいlongValuesの中身は単純なループです。引数で受け取ったdocs配列をforで回し、各docに対してadvanceExact(doc)が成功すればlongValue()の結果を、失敗すればdefaultValueをvalues[i]へ書き込みます。変更前のコードはnorms.advanceExactとnorms.longValueをループ内で繰り返し呼んでいました。
ここで効いてくるのが、ループの内側にいる時点で対象オブジェクトが確定しているという点です。すでにそのオブジェクトのlongValueの中に入っているため、ここでのlongValue()呼び出しに仮想メソッドの概念は持ち込まれず、直接呼び出しになります。これがbulk APIの実体です。

2つ目の変更が、コレクタへの入力をバッチ化する仕組みです。stream.intoArray(docBuffer)がドキュメントIDをまとめてバッファへ流し込み、それをwhileの条件にしながらcount件ずつ取り出します。取り出した塊に対してvalues.longValues(count, docBuffer, valueBuffer, Long.MIN_VALUE)を呼び、そのあとはローカルなタイトループで集約を行います。
この形にすることで、ドキュメント1件ごとのコレクションがチャンク単位の処理に置き換わります。ホットループ内での仮想ディスパッチの圧力が下がり、結果としてインライン化の機会が増えます。

3つ目の変更が、コレクションのパスをレンジ指向にすることです。従来のcollectはドキュメントごとに呼び出していました。これに代えて、対象としたい範囲の開始位置と終了位置を渡すcollector.collectRange(minDoc, maxDoc)を導入します。範囲を与えるとそれを基にdoc IDのストリームが作られ、collectがまとめて呼ばれます。
これにより仮想ディスパッチのオーバーヘッドが減り、CPU効率が上がり、JITの最適化が効きやすくなります。

変更前のPer-Doc Collectionでは、doc IDごとにcollect(doc)が並び、その都度Virtual Callが発生してキャッシュ局所性も悪い状態でした。変更後のBatch Collectionでは、doc IDをまとめてDocIdStreamにし、collectRange(startDoc, endDoc)でドキュメントの塊を一括処理します。仮想呼び出しが減り、キャッシュ効率が改善します。これは前半のJavaの小さな例と直接対応しています。
こうしたbulk collection APIはまだ始まりにすぎません。JavaのPanama API(Vector APIなどのネイティブ連携・ベクトル化機構)がSIMD命令を扱えるようになっており、ここで整えたバッチ処理の道筋はSIMD命令へ直接つなげていける見込みです。
gRPCトランスポートとREST/JSONの違い

gRPCはGoogleが開発したRPCフレームワークで、Protocol Buffersを使ってデータをバイナリ形式でやり取りします。OpenSearchが従来用いてきたREST/JSON over HTTPと比べると、その違いは前半で論じたCPU時間の使われ方の話と重なります。REST/JSONは柔軟で扱いやすい一方、テキストのペイロードをパースする処理がそのままオーバーヘッドになります。
gRPCはバイナリで型付きのトランスポート層を提供するため、このパースのコストを抑えられます。結果として低レイテンシかつ高スループットが求められるアプリケーションで効いてきます。
ただし、gRPCはRESTをあらゆる場面で置き換えることを目指したものではありません。万能薬でもなく、効果が出るワークロードと出ないワークロードがあります。私たちの狙いは、適したワークロードに対してより速い経路を用意することにあります。

gRPCサポートはOpenSearch 3.0で導入されました。その後、Bulkとk-NNのgRPC APIは3.2で一般提供(GA)になり、ingestionについては3.3でGAに到達しています。一方、Search向けのgRPC APIは一部のクエリタイプにしか対応しておらず、現時点でも実験的な位置づけです。対応するクエリタイプは増えてきていますが、まだ全種類を網羅してはいません。
gRPCトランスポートはデフォルトでは有効になっておらず、設定で明示的に有効化する必要があります。opensearch.ymlでaux.transport.typesにtransport-grpcを指定し、aux.transport.transport-grpc.portでポート範囲(例として9400-9500)を設定します。
クライアント側からgRPCを使うには、OpenSearchのprotobuf定義を取り込み、サーバー側でgRPCトランスポートを有効にしておく必要があります。protobuf定義はリクエストとレスポンスのスキーマにあたり、これをもとにクライアントのスタブを生成します。
検索クエリへ広がったgRPCの対応範囲

OpenSearch 3.4 では、gRPC 経由で扱えるクエリタイプが大きく増えました。これまでの gRPC は ingestion パイプライン向けに作られた性格が強く、検索に使う場合はどのクエリが対応しているかを慎重に選ぶ必要がありました。3.4 ではその制約が緩み、検索でも gRPC を使える段階に入ったというのが私たちの見立てです。
クエリレベルで追加されたのは ConstantScoreQuery、FuzzyQuery、MatchBoolPrefixQuery、MatchPhrasePrefix、PrefixQuery、MatchQuery です。MatchQuery は通常の全文検索を担い、OpenSearch で最もよく使われるクエリのひとつです。FuzzyQuery はタイプミスへの許容(typo tolerance)に効きます。MatchBoolPrefixQuery と MatchPhrasePrefix は search-as-you-type やオートコンプリート的な体験に向き、PrefixQuery は構造化された接頭辞のマッチングに使えます。ConstantScoreQuery は、スコアの差よりもフィルタリングそのものが重要な場面で有効です。
なお gRPC でまだ対応していないクエリタイプも残っていますが、ベクトル検索の用途では現時点でも gRPC が有力な選択肢になります。k-NN のケースではスループットで 20〜30% の改善というベンチマーク結果が出ています。

MatchQuery が gRPC で使えるようになったことで、商品検索のような一般的なアプリケーション検索パターンが実用的になりました。スライドの例では grpcurl -plaintext で products インデックスに対し、title フィールドを wireless noise cancelling headphones で match 検索しています。あて先は localhost:9400 の org.opensearch.protobufs.services.SearchService/Search です。
リクエストボディは REST の検索 API と同じ構造を search_request_body の下に持ち、size や query をそのまま記述します。以前はこうした全文検索を gRPC で投げることはできませんでしたが、配線さえ済ませれば普段の検索がそのまま通るようになっています。

オートコンプリートとタイプミス許容も、同じ要領で gRPC から呼べます。スライドには 2 つの例が並んでいます。ひとつは match_bool_prefix で title を wireless hea のような入力途中の文字列にマッチさせるもので、search-as-you-type に向きます。もうひとつは fuzzy で brand フィールドを applw という綴り間違いに対してマッチさせるもので、ユーザーが用語をミスタイプしたときに効きます。
要点は、新しいクエリ用にクライアント側を作り変える必要がないことです。REST で書いていたクエリの構造をそのまま gRPC に載せられます。

Bulk 側も 3.4 で手が入りました。gRPC Bulk は JSON に加えて CBOR、SMILE、YAML のドキュメントフォーマットに対応します。CBOR と SMILE はいずれも JSON 互換のバイナリ表現で、サイズやパース効率の面で利点があります。
gRPC はバイト列からドキュメントフォーマットを自動判別できます。REST の Bulk が NDJSON の行区切りパースに縛られるのに対し、gRPC は同じ制約を受けません。あわせて、リクエスト処理まわりのバグ修正と改善も入り、update のハンドリングがより適切になり、バイト処理も効率化されています。

フォーマットを混在させた Bulk ingestion も可能になりました。スライドの例では movies インデックスへの bulk_request_body に 3 件の create 操作が並び、json-doc-1 には base64 化した JSON バイト、smile-doc-1 には SMILE バイト、yaml-doc-1 には YAML バイトがそれぞれ object として入っています。1 つのリクエストの中で異なるフォーマットのドキュメントを混ぜられるわけです。
これを支えているのが Protocol Buffers の性質です。protobuf はメッセージ境界が明示的なため、ドキュメントのバイト列をリクエスト項目ごとに解釈できます。オブジェクトを載せてしまえばフォーマットを指定する必要はなく、そのまま流すだけで処理されます。NDJSON ベースの Bulk フローでは扱いにくい、あるいは不可能だったケースを gRPC で処理できるのはこのためです。

gRPC は OpenSearch に、高スループット用途向けの低オーバーヘッドで型付けされたトランスポートの選択肢を与えます。3.4 では検索 API に実用的なクエリタイプが加わり、Bulk も複数フォーマット対応とリクエスト処理の修正で改善しました。結果として gRPC ベースのクライアントにとって開発体験がよくなり、適用範囲も広がっています。
ひとつ注意点があります。gRPC Search は一部のクエリタイプについてまだ experimental の扱いで、本番投入前に自分たちのワークロードで検証すべきです。ただし完全に未成熟というわけでもなく、最大のコントリビューターである Uber が実運用でこれを使い込んでいることを考えると、相応に安定してきているとも言えます。
これら一連の変更が示すのは、OpenSearch が性能改善を Lucene のアグリゲーション内部からクライアントとクラスター間のトランスポート効率まで、複数のレイヤーで同時に進めているということです。すべてが一足飛びに完成しているわけではありませんが、取り組みは継続しています。
まとめ
前半の Lucene bulk collection API は、型ごとにまとめて処理することで仮想ディスパッチを減らし JIT のインライン化を効かせるという発想を、doc-values の一括取得とレンジ指向のコレクションへ落とし込み、最大約40%の性能改善につなげています。後半の gRPC は、バイナリで型付きのトランスポートを用意することで REST/JSON のパースコストを避け、3.4 で検索クエリと複数フォーマットの Bulk まで適用範囲を広げました。アグリゲーションの内部からクライアントとクラスター間のトランスポートまで、OpenSearch の性能改善が複数のレイヤーで並行して進んでいることがうかがえます。いずれもまだ発展途上であり、gRPC Search のように本番投入前に自分たちのワークロードで検証すべき部分も残っています。
Q&A
質問1: 最後の例はgRPCのはずですが、見た目はJSONですよね。これはOpenSearchが依然としてJSONをパースする必要があるという意味ではないでしょうか。バイナリ形式でデータを送るだけにはできないのですか。
回答: ご指摘の通りで、そこが弱点になっています。gRPCで送ってもサーバー内部ではJSONに変換されて処理されるため、即座に大きなパフォーマンス上の利点が得られるわけではありません。内部的にはCBORのような形式が使われていたと記憶していますが、いずれにせよ最終的にはJSONを経由します。
質問2: プリミティブなデータ型だけのgRPCにして、JSONを一切使わない形にはできないのですか。
回答: 現時点ではできません。なぜ最初から最後までバイナリ形式で通さないのか、という疑問はもっともです。bulkから着手したのは、少なくともトランスポート層はより効率的になるからです。大量のドキュメントをbulk ingestionする場合、トランスポートがボトルネックになっているのであれば、その部分では削減効果が得られます。ただしサーバー内部に入ってからはすべてJSONとして扱われます。エンドツーエンドのバイナリ化はこれから取り組んでいく予定です。