Bering Note – formerly 流沙河鎮

情報技術系のこと書きます。

OpenSearchの検索速度を向上するConcurrent segment searchを理解する

OpenSearchはスケーラブルで多様なユースケースに対応できるオープンソース検索エンジンです。本記事では、OpenSearchの検索性能を向上させる仕組みである「Concurrent segment search」について解説します。

Concurrent segment searchは、従来直列に処理されていたセグメントへの検索を並列に実行することで、検索を高速化すると共に、リソースの効率的な利用を可能にします。
本機能は、OpenSearch 3.0以降ではデフォルトで一部のクエリに自動適用されます。また、Amazon OpenSearch Serviceでは、2.17以降の一部条件を満たすドメインでデフォルトで一部のクエリに自動適用されます。
こうした状況もあって、Concurrent segment searchは今後多くのユーザーに利用されることが予想されます。

OpenSearchはユーザーがそれほど意識しなくてもConcurrent segment searchを活用できるように設計されています。一方で仕組みを理解しておくことで、効果的なユースケースやチューニングの考慮点を踏まえた最適化が可能になります。

以降の文章は、特別な断りがないかぎり、OpenSearch 3.0以降を前提にしています。

セグメントとパフォーマンスの関係性

OpenSearchにおけるセグメント


OpenSearchは、Apache Luceneをベースとする分散システムです、クラスターは複数のデータノードで構成され、各インデックスは複数のシャードに分割してノードに分散配置されます。
シャードは、OpenSearchが内部的に使用するLuceneのインデックスです。1 Luceneのインデックス内部のデータはセグメントと呼ばれる単位で管理されています。
インデックスを構成するシャードにはプライマリシャードとレプリカシャードがあります。プライマリシャードは書き込みや更新を受け付ける一方、レプリカシャードはプライマリシャードのコピーとして読み取り性能を向上させると共に、データを冗長化する役割を持ちます。

Concurrent segment searchを使用しない場合の検索の流れ


検索リクエストを受け取ったノードはコーディネーターとして機能し、どのインデックスとシャードを検索する必要があるかを評価し、各シャードに対して検索リクエストを送信します。
シャードへの検索リクエストは、それぞれのシャードが扱うデータの範囲ごとに、単一のプライマリシャードないしレプリカシャードに対して送信されます。つまり、レプリカシャードが複数存在する場合でも、単一のクエリはプライマリシャードの数と同じ数しか並列処理されません。検索性能の観点では、レプリカシャードは複数のクエリを同時に処理するスループットの向上にのみ寄与します。
Concurrent segment searchを使用しない場合、シャードへの検索は各セグメントに対して単一のスレッドでシーケンシャルに実行されます。セグメント単位の検索後、それぞれの結果が収集され、シャード単位の結果としてまとめられます。それらはコーディネーターへ返却され、最終的な検索結果がクライアントに返されます。
ここでのポイントは、各シャードへの検索は単一のスレッドで処理されるため、検索クエリ性能のスケールがシャード数に依存することです。つまり、データノードの論理CPUコア数がプライマリシャード数を超える場合、余剰の論理CPUコアは検索処理に利用されません。2 単一検索クエリを処理する際の並列数を増やすためには、プライマリシャード数を増やす必要があります。3


Concurrent segment searchは、シャードに対する検索を並列に実行することで性能を向上させる機能です。 Concurrent segment searchが適用されている場合、クエリフェーズでセグメントが複数の「スライス(Slice)」と呼ばれる単位に分割されます。各スライスは、異なるスレッドが並列で処理できます。つまり、スライスの数だけシャードの検索処理を並列に実行できるようになります。全てのスライスが処理を完了すると、Luceneはスライス間で結果をマージし、最終的なシャード単位の結果を作成します。
なお、スライスへの処理は、シャード単位の検索リクエストを処理するsearchスレッドプールではなく、index_searcherスレッドプールを使用して実行されます。index_searcherスレッドプールの数は、デフォルトでは利用可能なプロセッサ数の2倍に設定されています。

Concurrent segment searchが適用される条件

Concurrent segment searchの適用有無

Concurrent segment searchの適用有無は、search.concurrent_segment_search.modeによって制御され、クラスターレベルとインデックスレベルで設定できます。両方が設定されている場合、インデックスレベルの設定が優先されます。
search.concurrent_segment_search.modeには、以下の値を設定できます。

  • auto: Concurrent segment searchの適用有無を動的に決定します。デフォルトでは、集約を含むクエリと、kNNの検索にConcurrent segment searchが適用されます。詳細なロジックは後述します。OpenSearch 3.0以降、及びAmazon OpenSearch Service 2.17以降では、デフォルトでautoが設定されます
  • all: 全ての検索リクエストに対してConcurrent segment searchを適用します
  • none: 全ての検索リクエストに対してConcurrent segment searchを適用しません

クラスタレベルの設定例:

PUT _cluster/settings
{
   "persistent":{
      "search.concurrent_segment_search.mode": "all"
   }
}

インデックスレベルの設定例:

PUT <index-name>/_settings
{
    "index.search.concurrent_segment_search.mode": "all"
}

autoモードの動作

autoモードでは、Concurrent segment searchの適用有無は、クエリの内容に基づいて動的に決定されます。
詳細な挙動は、DefaultSearchContext.java周辺の実装を参照することで確認できます。

まず、以下の条件を満たす場合は、search.concurrent_segment_search.modeの設定値に関係なく、Concurrent Segment Searchを行いません。

  • ソートがTime Series Fieldで行われている
  • terminateAfterが1以上である(デフォルト値である0以外である)
/**
* Evaluate if request should use concurrent search based on request and concurrent search deciders
*/
public void evaluateRequestShouldUseConcurrentSearch() {
   if (sort != null && sort.isSortOnTimeSeriesField()) {
      requestShouldUseConcurrentSearch.set(false);
   } else if (aggregations() != null
      && aggregations().factories() != null
      && !aggregations().factories().allFactoriesSupportConcurrentSearch()) {
            requestShouldUseConcurrentSearch.set(false);
      } else if (terminateAfter != DEFAULT_TERMINATE_AFTER) {
            requestShouldUseConcurrentSearch.set(false);
      } else if (concurrentSearchMode.equals(CONCURRENT_SEGMENT_SEARCH_MODE_AUTO)) {
            requestShouldUseConcurrentSearch.set(evaluateAutoMode());
      } else {
            requestShouldUseConcurrentSearch.set(true);
      }
}

DefaultSearchContext.java

  • System Indexである(indexShard.isSystem())
  • Search Throttleが有効である(indexShard.indexSettings().isSearchThrottled())
  • Cluster Serviceが利用できない(clusterService == null)
  • Concurrent Search Executorが利用できない(concurrentSearchExecutor == null)
// Skip concurrent search for system indices, throttled requests, or if dependencies are missing
if (indexShard.isSystem()
   || indexShard.indexSettings().isSearchThrottled()
   || clusterService == null
   || concurrentSearchExecutor == null) {
   return CONCURRENT_SEGMENT_SEARCH_MODE_NONE;
} 

DefaultSearchContext.java

次に、Pluggable concurrent search decidersを使用して、Concurrent segment searchの適用有無を決定します。
ConcurrentSearchRequestDeciderを実装したプラグインがある場合、ConcurrentSearchRequestDecider#evaluateForQuery()メソッドが呼び出され、クエリの各ノードに対して評価が行われます。
デフォルトでは、knnプラグインによりKNNConcurrentSearchRequestDeciderが登録されており、検索がkNNである場合にConcurrent segment searchを適用します。

@Override
public void evaluateForQuery(final QueryBuilder queryBuilder, final IndexSettings indexSettings) {
   if (queryBuilder instanceof KNNQueryBuilder && indexSettings.getValue(KNNSettings.IS_KNN_INDEX_SETTING)) {
      knnDecision = YES;
   } else {
      knnDecision = DEFAULT_KNN_DECISION;
   }
}

KNNConcurrentSearchRequestDecider.java

最後に、検索クエリが集約を含むかどうかを確認します。集約を含む場合、Aggregation FactoryがConcurrent segment searchをサポートしているかを確認し、すべてのファクトリがサポートしている場合にConcurrent segment searchを適用します。

/**
* Evaluate if request should use concurrent search based on request and concurrent search deciders
*/
public void evaluateRequestShouldUseConcurrentSearch() {
   if (sort != null && sort.isSortOnTimeSeriesField()) {
      requestShouldUseConcurrentSearch.set(false);
   } else if (aggregations() != null
      && aggregations().factories() != null
      && !aggregations().factories().allFactoriesSupportConcurrentSearch()) {
            requestShouldUseConcurrentSearch.set(false);
      } else if (terminateAfter != DEFAULT_TERMINATE_AFTER) {
            requestShouldUseConcurrentSearch.set(false);
      } else if (concurrentSearchMode.equals(CONCURRENT_SEGMENT_SEARCH_MODE_AUTO)) {
            requestShouldUseConcurrentSearch.set(evaluateAutoMode());
      } else {
            requestShouldUseConcurrentSearch.set(true);
      }
}

スライス数の決まり方

シャードを並列処理する際の単位であるスライスの数を決定するメカニズムには、max slice count mechanismとLucene mechanismの2つがあります。
使用されるメカニズムはsearch.concurrent.max_slice_countによって制御され、クラスターレベルとインデックスレベルで設定できます。

クラスタレベルの設定例:

PUT _cluster/settings
{
   "persistent":{
      "search.concurrent.max_slice_count": 2
   }
}

インデックスレベルの設定例:

PUT <index-name>/_settings
{
    "index.search.concurrent.max_slice_count": 2
}

search.concurrent.max_slice_count0に設定されている場合、Lucene mechanismが使用されます。それ以外の値が設定されている場合、max slice count mechanismが使用されます。
OpenSearchでは、クラスタ起動時にデータノードのMath.max(1, Math.min(Runtime.getRuntime().availableProcessors() / 2, 4))が計算され、クラスタレベルのデフォルト値として設定されます。
Amazon OpenSearch Serviceでは、デフォルトで2が設定されます。
従って、OpenSearch, Amazon OpenSearch Serviceともに、ノードの論理CPUコア数が4未満である場合を除いてデフォルトではmax slice count mechanismが使用されます。

max slice count mechanism

max slice count mechanismは、search.concurrent.max_slice_countの値のスライス数にセグメントを分割する方式です。セグメントの数がmax_slice_countよりも少ない場合は、セグメントの数がスライス数となります。
スライス数が固定されているため、並列処理の度合いやリソースの消費を制御しやすい特徴があります。先述の通りデフォルトではmax_slice_countには4を最大値とする比較的保守的な値が設定されるため、これをベースラインとして、必要に応じてインデックスレベルでの調整を行うのが良いでしょう。

Lucene mechanism

Lucene mechanismは、Luceneのデフォルトのロジックに基づいて動的にスライス数を決定します。Luceneでは、各スライスに最大250,000ドキュメントまたは5セグメント(どちらか早く満たされる方)を割り当てます。
例えば、11セグメントのシャードがある場合、最初の5セグメントが250,000ドキュメントずつ含まれ、次の6セグメントが20,000ドキュメントずつ含まれているとします。この場合、最初の5セグメントはそれぞれ1スライスに割り当てられます。次の5セグメントは、1つのスライスにまとめられます。最後の11番目のセグメントは別のスライスに割り当てられます。
Lucene mechanismで2つ以上のスライスを持つためには、1シャードあたり250,000ドキュメントか、5セグメント、つまりセグメントのデフォルトマージポリシーでは、1セグメント5GBとなるため、1シャードあたり25GB以上のデータが必要になります。それを下回る場合は、1スライスのみとなり、実質的にConcurrent segment searchは適用されません。また、逆にドキュメント数やセグメント数が多い場合は、スライス数が増加していきます。

search.concurrent.max_slice_countの挙動の詳細

search.concurrent.max_slice_count周辺の実装を見ていくと、スライス数を決定するロジックの詳細がわかります。

まず、以下のようにしてスライスの生成に使用するメカニズムを決定します。

// package-private for testing
LeafSlice[] slicesInternal(List<LeafReaderContext> leaves, int targetMaxSlice) {
   LeafSlice[] leafSlices;
   if (targetMaxSlice == 0) { // max_slice_count設定が0の場合はlucene方式のアルゴリズムでSlice数を計算する
      // use the default lucene slice calculation
      leafSlices = super.slices(leaves);
      logger.debug("Slice count using lucene default [{}]", leafSlices.length);
   } else {
      // use the custom slice calculation based on targetMaxSlice
      leafSlices = MaxTargetSliceSupplier.getSlices(leaves, targetMaxSlice);
      logger.debug("Slice count using max target slice supplier [{}]", leafSlices.length);
   }
   return leafSlices;
}

ContextIndexSearcher.java

max slice count mechanismが使用される場合、MaxTargetSliceSupplier.getSlices()メソッドが呼び出されます。
ここでは、各スライスのドキュメント数がなるべく均等になるように、セグメントをmaxDoc(最大ドキュメント数)の降順でソートし、ラウンドロビン方式でmax_slice_countの数だけスライスに分配します。

static IndexSearcher.LeafSlice[] getSlices(List<LeafReaderContext> leaves, int targetMaxSlice) {
   if (targetMaxSlice <= 0) {
      throw new IllegalArgumentException("MaxTargetSliceSupplier called with unexpected slice count of " + targetMaxSlice);
   }

   // slice count should not exceed the segment count
   int targetSliceCount = Math.min(targetMaxSlice, leaves.size()); // max_slice_countがセグメント数を超えない限り、max_slice_count数のSliceにセグメントを分割するようにtargetSliceCountを設定する。セグメント数の方が小さい場合、セグメント数がtargetSliceCountとなる

    // セグメントのソート(大きい順)
    List<LeafReaderContext> sortedLeaves = new ArrayList<>(leaves);
    sortedLeaves.sort(Collections.reverseOrder(Comparator.comparingInt(l -> l.reader().maxDoc())));

    // Sliceグループの初期化
    final List<List<IndexSearcher.LeafReaderContextPartition>> groupedLeaves = new ArrayList<>(targetSliceCount);
    for (int i = 0; i < targetSliceCount; ++i) {
        groupedLeaves.add(new ArrayList<>());
    }

    // ラウンドロビンで分配
    for (int idx = 0; idx < sortedLeaves.size(); ++idx) {
        int currentGroup = idx % targetSliceCount;
        groupedLeaves.get(currentGroup).add(
            IndexSearcher.LeafReaderContextPartition.createForEntireSegment(sortedLeaves.get(idx))
        );
    }

    // LeafSlice配列に変換
    return groupedLeaves.stream().map(IndexSearcher.LeafSlice::new).toArray(IndexSearcher.LeafSlice[]::new);
}

MaxTargetSliceSupplier.java

Concurrent segment searchの挙動を観察する

OpenSearch 3.0を使用して、Concurrent segment searchの実際の挙動を観察してみましょう。

検証のため、以下の環境を用意しました。r7i.2xlarge 3台のクラスターです。

GET /
{
  "name": "node-1",
  "cluster_name": "production-cluster",
  "cluster_uuid": "9GUcbjnPRUqWIFXyRomxxw",
  "version": {
    "distribution": "opensearch",
    "number": "3.0.0",
    "build_type": "tar",
    "build_hash": "dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9",
    "build_date": "2025-05-03T06:25:26.379676844Z",
    "build_snapshot": false,
    "lucene_version": "10.1.0",
    "minimum_wire_compatibility_version": "2.19.0",
    "minimum_index_compatibility_version": "2.0.0"
  },
  "tagline": "The OpenSearch Project: https://opensearch.org/"
}

まず、Concurrent segment searchのクラスタレベルのデフォルト設定を確認します。

# GET _cluster/settings?include_defaultsより抜粋

"concurrent_segment_search": {
   "mode": "auto",
   "enabled": "false"
},

"concurrent": {
"max_slice_count": "4"
},

ここでは、search.concurrent_segment_search.modeautoに設定されていることが分かります。search.concurrent_segment_search.enabledfalseに設定されているのが紛らわしいですが、これはConcurrent segment searchの機能が無効化されているわけではありません。search.concurrent_segment_search.enabledは古いバージョンのOpenSearchで使用されていた設定で現在は非推奨となっており、search.concurrent_segment_search.modeが優先されます。
search.concurrent.max_slice_count4に設定されています。r7i.2xlargeは8vCPUを持つため、Math.max(1, Math.min(Runtime.getRuntime().availableProcessors() / 2, 4))の計算結果と一致します。

次に、Concurrent segment searchの挙動を観察するためのインデックスを作成します。
ここでは、opensearch-benchmark-workloads/nyc_taxis/を使用して、nyc_taxisインデックスを作成しました。

クリックすると展開されます(インデックスの詳細)

GET /nyc_taxis

{
  "nyc_taxis": {
    "aliases": {},
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "cab_color": {
          "type": "keyword"
        },
        "dropoff_datetime": {
          "type": "date",
          "format": "yyyy-MM-dd HH:mm:ss"
        },
        "dropoff_location": {
          "type": "geo_point"
        },
        "ehail_fee": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "extra": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "fare_amount": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "improvement_surcharge": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "mta_tax": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "passenger_count": {
          "type": "integer"
        },
        "payment_type": {
          "type": "keyword"
        },
        "pickup_datetime": {
          "type": "date",
          "format": "yyyy-MM-dd HH:mm:ss"
        },
        "pickup_location": {
          "type": "geo_point"
        },
        "rate_code_id": {
          "type": "keyword"
        },
        "store_and_fwd_flag": {
          "type": "keyword"
        },
        "surcharge": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "tip_amount": {
          "type": "half_float"
        },
        "tolls_amount": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "total_amount": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "trip_distance": {
          "type": "scaled_float",
          "scaling_factor": 100
        },
        "trip_type": {
          "type": "keyword"
        },
        "vendor_id": {
          "type": "keyword"
        },
        "vendor_name": {
          "type": "text"
        }
      }
    },
    "settings": {
      "index": {
        "replication": {
          "type": "DOCUMENT"
        },
        "codec": "best_compression",
        "refresh_interval": "30s",
        "number_of_shards": "1",
        "translog": {
          "flush_threshold_size": "4g"
        },
        "provided_name": "nyc_taxis",
        "creation_date": "1752978502984",
        "requests": {
          "cache": {
            "enable": "false"
          }
        },
        "number_of_replicas": "0",
        "queries": {
          "cache": {
            "enabled": "false"
          }
        },
        "uuid": "G7jfr1cKSFyOzDLn7rt2ag",
        "version": {
          "created": "137217827"
        }
      }
    }
  }
}

GET /nyc_taxis/_count
{
  "count": 151899999,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  }
}

それでは、集約を含む検索クエリを実行してみます。Concurrent segment searchが適用されることを確認するため、profileオプションを使用します。

GET /nyc_taxis/_search
{
  "profile": true,
  "size": 0,
  "aggs": {
    "cab_color_count": {
      "terms": {
        "field": "passenger_count",
        "size": 10
      }
    }
  }
}

簡易的なセットアップであったこともあり、クエリの実行には5,795ミリ秒を要しました。
実行結果のプロファイル情報から、collectorセクションにスライス数が表示されており、Concurrent segment searchが適用されていることを確認できます。

   "collector": [
      {
         "name": "QueryCollectorManager",
         "reason": "search_multi",
         "time_in_nanos": 4435234720,
         "reduce_time_in_nanos": 187608,
         "max_slice_time_in_nanos": 4435234720,
         "min_slice_time_in_nanos": 3007289382,
         "avg_slice_time_in_nanos": 3955022213,
         "slice_count": 4,
         "children": [
         {
            "name": "EarlyTerminatingCollectorManager",
            "reason": "search_count",
            "time_in_nanos": 210200,
            "reduce_time_in_nanos": 9049,
            "max_slice_time_in_nanos": 41813,
            "min_slice_time_in_nanos": 24678,
            "avg_slice_time_in_nanos": 33665,
            "slice_count": 4
         },
         {
            "name": "NonGlobalAggCollectorManager: [passenger_count]",
            "reason": "aggregation",
            "time_in_nanos": 2023890727,
            "reduce_time_in_nanos": 163491,
            "max_slice_time_in_nanos": 2023890727,
            "min_slice_time_in_nanos": 1388782304,
            "avg_slice_time_in_nanos": 1808951770,
            "slice_count": 4
         }
         ]
      }
   ]
   }
],

aggregationsセクションには、スライスごとの集約の実行時間が表示されており、平均1613782553ナノ秒の時間がかかっていることがわかります。

        "aggregations": [
          {
            "type": "NumericTermsAggregator",
            "description": "passenger_count",
            "time_in_nanos": 1793018961,
            "max_slice_time_in_nanos": 1793018961,
            "min_slice_time_in_nanos": 1266438461,
            "avg_slice_time_in_nanos": 1613782553,
         ...

クリックすると展開されます(実行結果のプロファイル情報全体)

{
  "took": 5777,
  "timed_out": false,
  "terminated_early": true,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "passenger_count": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": 1,
          "doc_count": 109708404
        },
        {
          "key": 2,
          "doc_count": 20406322
        },
        {
          "key": 5,
          "doc_count": 7958772
        },
        {
          "key": 3,
          "doc_count": 6007703
        },
        {
          "key": 6,
          "doc_count": 4948140
        },
        {
          "key": 4,
          "doc_count": 2825772
        },
        {
          "key": 0,
          "doc_count": 43363
        },
        {
          "key": 8,
          "doc_count": 649
        },
        {
          "key": 7,
          "doc_count": 573
        },
        {
          "key": 9,
          "doc_count": 301
        }
      ]
    }
  },
  "profile": {
    "shards": [
      {
        "id": "[r15K5m_LQkihEi6R7pUMFA][nyc_taxis][0]",
        "inbound_network_time_in_millis": 0,
        "outbound_network_time_in_millis": 0,
        "searches": [
          {
            "query": [
              {
                "type": "ConstantScoreQuery",
                "description": "ConstantScore(*:*)",
                "time_in_nanos": 5770493547,
                "max_slice_time_in_nanos": 5770260477,
                "min_slice_time_in_nanos": 3884054712,
                "avg_slice_time_in_nanos": 5140437894,
                "breakdown": {
                  "max_match": 0,
                  "set_min_competitive_score_count": 0,
                  "match_count": 0,
                  "avg_score_count": 0,
                  "shallow_advance_count": 0,
                  "next_doc": 5770248407,
                  "min_build_scorer": 3883280853,
                  "score_count": 0,
                  "compute_max_score_count": 0,
                  "advance": 0,
                  "min_set_min_competitive_score": 0,
                  "min_advance": 0,
                  "score": 0,
                  "avg_set_min_competitive_score_count": 0,
                  "min_match_count": 0,
                  "avg_score": 0,
                  "max_next_doc_count": 42619814,
                  "max_compute_max_score_count": 0,
                  "avg_shallow_advance": 0,
                  "max_shallow_advance_count": 0,
                  "set_min_competitive_score": 0,
                  "min_build_scorer_count": 12,
                  "next_doc_count": 151900024,
                  "min_match": 0,
                  "avg_next_doc": 5140423990,
                  "compute_max_score": 0,
                  "min_set_min_competitive_score_count": 0,
                  "max_build_scorer": 5767935282,
                  "avg_match_count": 0,
                  "avg_advance": 0,
                  "build_scorer_count": 50,
                  "avg_build_scorer_count": 12,
                  "min_next_doc_count": 28704896,
                  "min_shallow_advance_count": 0,
                  "max_score_count": 0,
                  "avg_match": 0,
                  "avg_compute_max_score": 0,
                  "max_advance": 0,
                  "avg_shallow_advance_count": 0,
                  "avg_set_min_competitive_score": 0,
                  "avg_compute_max_score_count": 0,
                  "avg_build_scorer": 5139269040,
                  "max_set_min_competitive_score_count": 0,
                  "advance_count": 0,
                  "max_build_scorer_count": 14,
                  "shallow_advance": 0,
                  "min_compute_max_score": 0,
                  "max_match_count": 0,
                  "create_weight_count": 1,
                  "build_scorer": 5767937420,
                  "max_set_min_competitive_score": 0,
                  "max_compute_max_score": 0,
                  "min_shallow_advance": 0,
                  "match": 0,
                  "max_shallow_advance": 0,
                  "avg_advance_count": 0,
                  "min_next_doc": 3884039948,
                  "max_advance_count": 0,
                  "min_score": 0,
                  "max_next_doc": 5770248407,
                  "create_weight": 11082,
                  "avg_next_doc_count": 37975006,
                  "max_score": 0,
                  "min_compute_max_score_count": 0,
                  "min_score_count": 0,
                  "min_advance_count": 0
                },
                "children": [
                  {
                    "type": "MatchAllDocsQuery",
                    "description": "*:*",
                    "time_in_nanos": 5768964579,
                    "max_slice_time_in_nanos": 5768736304,
                    "min_slice_time_in_nanos": 3883546398,
                    "avg_slice_time_in_nanos": 5139672515,
                    "breakdown": {
                      "max_match": 0,
                      "set_min_competitive_score_count": 0,
                      "match_count": 0,
                      "avg_score_count": 0,
                      "shallow_advance_count": 0,
                      "next_doc": 5768724184,
                      "min_build_scorer": 3883277949,
                      "score_count": 0,
                      "compute_max_score_count": 0,
                      "advance": 0,
                      "min_set_min_competitive_score": 0,
                      "min_advance": 0,
                      "score": 0,
                      "avg_set_min_competitive_score_count": 0,
                      "min_match_count": 0,
                      "avg_score": 0,
                      "max_next_doc_count": 42619814,
                      "max_compute_max_score_count": 0,
                      "avg_shallow_advance": 0,
                      "max_shallow_advance_count": 0,
                      "set_min_competitive_score": 0,
                      "min_build_scorer_count": 12,
                      "next_doc_count": 151900024,
                      "min_match": 0,
                      "avg_next_doc": 5139659915,
                      "compute_max_score": 0,
                      "min_set_min_competitive_score_count": 0,
                      "max_build_scorer": 5767934748,
                      "avg_match_count": 0,
                      "avg_advance": 0,
                      "build_scorer_count": 50,
                      "avg_build_scorer_count": 12,
                      "min_next_doc_count": 28704896,
                      "min_shallow_advance_count": 0,
                      "max_score_count": 0,
                      "avg_match": 0,
                      "avg_compute_max_score": 0,
                      "max_advance": 0,
                      "avg_shallow_advance_count": 0,
                      "avg_set_min_competitive_score": 0,
                      "avg_compute_max_score_count": 0,
                      "avg_build_scorer": 5139266890,
                      "max_set_min_competitive_score_count": 0,
                      "advance_count": 0,
                      "max_build_scorer_count": 14,
                      "shallow_advance": 0,
                      "min_compute_max_score": 0,
                      "max_match_count": 0,
                      "create_weight_count": 1,
                      "build_scorer": 5767936429,
                      "max_set_min_competitive_score": 0,
                      "max_compute_max_score": 0,
                      "min_shallow_advance": 0,
                      "match": 0,
                      "max_shallow_advance": 0,
                      "avg_advance_count": 0,
                      "min_next_doc": 3883534085,
                      "max_advance_count": 0,
                      "min_score": 0,
                      "max_next_doc": 5768724184,
                      "create_weight": 2059,
                      "avg_next_doc_count": 37975006,
                      "max_score": 0,
                      "min_compute_max_score_count": 0,
                      "min_score_count": 0,
                      "min_advance_count": 0
                    }
                  }
                ]
              }
            ],
            "rewrite_time": 6983,
            "collector": [
              {
                "name": "QueryCollectorManager",
                "reason": "search_multi",
                "time_in_nanos": 4420988733,
                "reduce_time_in_nanos": 190408,
                "max_slice_time_in_nanos": 4420988733,
                "min_slice_time_in_nanos": 2976758371,
                "avg_slice_time_in_nanos": 3938258119,
                "slice_count": 4,
                "children": [
                  {
                    "name": "EarlyTerminatingCollectorManager",
                    "reason": "search_count",
                    "time_in_nanos": 185333,
                    "reduce_time_in_nanos": 9269,
                    "max_slice_time_in_nanos": 35263,
                    "min_slice_time_in_nanos": 27253,
                    "avg_slice_time_in_nanos": 32663,
                    "slice_count": 4
                  },
                  {
                    "name": "NonGlobalAggCollectorManager: [passenger_count]",
                    "reason": "aggregation",
                    "time_in_nanos": 2014438037,
                    "reduce_time_in_nanos": 167479,
                    "max_slice_time_in_nanos": 2014438037,
                    "min_slice_time_in_nanos": 1358235567,
                    "avg_slice_time_in_nanos": 1796104326,
                    "slice_count": 4
                  }
                ]
              }
            ]
          }
        ],
        "aggregations": [
          {
            "type": "NumericTermsAggregator",
            "description": "passenger_count",
            "time_in_nanos": 1794503517,
            "max_slice_time_in_nanos": 1794503517,
            "min_slice_time_in_nanos": 1206522133,
            "avg_slice_time_in_nanos": 1596812560,
            "breakdown": {
              "min_build_leaf_collector": 120251,
              "build_aggregation_count": 4,
              "post_collection": 1887006614,
              "max_collect_count": 42619807,
              "initialize_count": 4,
              "reduce_count": 0,
              "avg_collect": 1596572942,
              "max_build_aggregation": 96213,
              "avg_collect_count": 37974999,
              "max_build_leaf_collector": 161175,
              "min_build_leaf_collector_count": 6,
              "build_aggregation": 1887100897,
              "min_initialize": 672,
              "max_reduce": 0,
              "build_leaf_collector_count": 25,
              "avg_reduce": 0,
              "min_collect_count": 28704890,
              "avg_build_leaf_collector_count": 6,
              "avg_build_leaf_collector": 140914,
              "max_collect": 1794278841,
              "reduce": 0,
              "avg_build_aggregation": 94702,
              "min_post_collection": 2370,
              "max_initialize": 3015,
              "max_post_collection": 2896,
              "collect_count": 151899999,
              "avg_post_collection": 2661,
              "avg_initialize": 1339,
              "post_collection_count": 4,
              "build_leaf_collector": 378310,
              "min_collect": 1206305864,
              "min_build_aggregation": 92626,
              "initialize": 259207,
              "max_build_leaf_collector_count": 7,
              "min_reduce": 0,
              "collect": 1794278841
            },
            "debug": {
              "result_strategy": "long_terms",
              "total_buckets": 10
            }
          }
        ]
      }
    ]
  }
}

それでは、search.concurrent_segment_search.mode=autoではConcurrent segment searchが適用されない、集約を含まない検索クエリを実行してみます。

GET /nyc_taxis/_search
{
  "profile": true,
  "size": 0,
  "query": {
    "match_all": {}
  }
}

結果にはConcurrent segment searchに関わる情報は含まれておらず、適用されなかったことがわかります。

クリックすると展開されます(実行結果のプロファイル情報全体)

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": null,
    "hits": []
  },
  "profile": {
    "shards": [
      {
        "id": "[r15K5m_LQkihEi6R7pUMFA][nyc_taxis][0]",
        "inbound_network_time_in_millis": 0,
        "outbound_network_time_in_millis": 0,
        "searches": [
          {
            "query": [
              {
                "type": "ConstantScoreQuery",
                "description": "ConstantScore(*:*)",
                "time_in_nanos": 15649,
                "breakdown": {
                  "set_min_competitive_score_count": 0,
                  "match_count": 0,
                  "shallow_advance_count": 0,
                  "next_doc": 0,
                  "score_count": 0,
                  "compute_max_score_count": 0,
                  "advance": 0,
                  "advance_count": 0,
                  "score": 0,
                  "shallow_advance": 0,
                  "create_weight_count": 1,
                  "build_scorer": 0,
                  "set_min_competitive_score": 0,
                  "match": 0,
                  "next_doc_count": 0,
                  "compute_max_score": 0,
                  "build_scorer_count": 0,
                  "create_weight": 15649
                },
                "children": [
                  {
                    "type": "MatchAllDocsQuery",
                    "description": "*:*",
                    "time_in_nanos": 4139,
                    "breakdown": {
                      "set_min_competitive_score_count": 0,
                      "match_count": 0,
                      "shallow_advance_count": 0,
                      "next_doc": 0,
                      "score_count": 0,
                      "compute_max_score_count": 0,
                      "advance": 0,
                      "advance_count": 0,
                      "score": 0,
                      "shallow_advance": 0,
                      "create_weight_count": 1,
                      "build_scorer": 0,
                      "set_min_competitive_score": 0,
                      "match": 0,
                      "next_doc_count": 0,
                      "compute_max_score": 0,
                      "build_scorer_count": 0,
                      "create_weight": 4139
                    }
                  }
                ]
              }
            ],
            "rewrite_time": 6255,
            "collector": [
              {
                "name": "EarlyTerminatingCollector",
                "reason": "search_count",
                "time_in_nanos": 10198
              }
            ]
          }
        ],
        "aggregations": []
      }
    ]
  }
}

続いて、インデックスレベルの設定により、Concurrent segment searchが適用されないようにしてみましょう。

PUT /nyc_taxis/_settings
{
  "index": {
    "search": {
      "concurrent_segment_search": {
        "mode": "none"
      }
    }
  }
}

改めて、先ほどと同じ集約を含む検索クエリを実行してみます。

GET /nyc_taxis/_search
{
  "profile": true,
  "size": 0,
  "aggs": {
    "passenger_count": {
      "terms": {
        "field": "passenger_count",
        "size": 10
      }
    }
  }
}

クエリの実行には20,262ミリ秒を要しました。
プロファイル情報には、Concurrent segment searchに関わる情報は含まれておらず、適用されなかったことがわかります。
Concurrent segment searchが適用されている場合の実行時間が5,795ミリ秒であり、スライス数が4であったことから、並列実行により概ね4倍の性能向上が得られていたことがわかります。

クリックすると展開されます(実行結果のプロファイル情報全体)

{
  "took": 20262,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "passenger_count": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": 1,
          "doc_count": 109708404
        },
        {
          "key": 2,
          "doc_count": 20406322
        },
        {
          "key": 5,
          "doc_count": 7958772
        },
        {
          "key": 3,
          "doc_count": 6007703
        },
        {
          "key": 6,
          "doc_count": 4948140
        },
        {
          "key": 4,
          "doc_count": 2825772
        },
        {
          "key": 0,
          "doc_count": 43363
        },
        {
          "key": 8,
          "doc_count": 649
        },
        {
          "key": 7,
          "doc_count": 573
        },
        {
          "key": 9,
          "doc_count": 301
        }
      ]
    }
  },
  "profile": {
    "shards": [
      {
        "id": "[r15K5m_LQkihEi6R7pUMFA][nyc_taxis][0]",
        "inbound_network_time_in_millis": 0,
        "outbound_network_time_in_millis": 0,
        "searches": [
          {
            "query": [
              {
                "type": "ConstantScoreQuery",
                "description": "ConstantScore(*:*)",
                "time_in_nanos": 13179936169,
                "breakdown": {
                  "set_min_competitive_score_count": 0,
                  "match_count": 0,
                  "shallow_advance_count": 0,
                  "next_doc": 13179849477,
                  "score_count": 0,
                  "compute_max_score_count": 0,
                  "advance": 0,
                  "advance_count": 0,
                  "score": 0,
                  "shallow_advance": 0,
                  "create_weight_count": 1,
                  "build_scorer": 77189,
                  "set_min_competitive_score": 0,
                  "match": 0,
                  "next_doc_count": 151900024,
                  "compute_max_score": 0,
                  "build_scorer_count": 50,
                  "create_weight": 9503
                },
                "children": [
                  {
                    "type": "MatchAllDocsQuery",
                    "description": "*:*",
                    "time_in_nanos": 4302108158,
                    "breakdown": {
                      "set_min_competitive_score_count": 0,
                      "match_count": 0,
                      "shallow_advance_count": 0,
                      "next_doc": 4302065637,
                      "score_count": 0,
                      "compute_max_score_count": 0,
                      "advance": 0,
                      "advance_count": 0,
                      "score": 0,
                      "shallow_advance": 0,
                      "create_weight_count": 1,
                      "build_scorer": 39705,
                      "set_min_competitive_score": 0,
                      "match": 0,
                      "next_doc_count": 151900024,
                      "compute_max_score": 0,
                      "build_scorer_count": 50,
                      "create_weight": 2816
                    }
                  }
                ]
              }
            ],
            "rewrite_time": 7585,
            "collector": [
              {
                "name": "MultiCollector",
                "reason": "search_multi",
                "time_in_nanos": 15524156051,
                "children": [
                  {
                    "name": "EarlyTerminatingCollector",
                    "reason": "search_count",
                    "time_in_nanos": 102861
                  },
                  {
                    "name": "ProfilingAggregator: [passenger_count]",
                    "reason": "aggregation",
                    "time_in_nanos": 7076630457
                  }
                ]
              }
            ]
          }
        ],
        "aggregations": [
          {
            "type": "NumericTermsAggregator",
            "description": "passenger_count",
            "time_in_nanos": 6317706950,
            "breakdown": {
              "reduce": 0,
              "build_aggregation_count": 1,
              "post_collection": 1329,
              "initialize_count": 1,
              "reduce_count": 0,
              "collect_count": 151899999,
              "post_collection_count": 1,
              "build_leaf_collector": 567315,
              "build_aggregation": 99223,
              "build_leaf_collector_count": 25,
              "initialize": 4307,
              "collect": 6317034776
            },
            "debug": {
              "result_strategy": "long_terms",
              "total_buckets": 10
            }
          }
        ]
      }
    ]
  }
}

最後に、全てのクエリに対してConcurrent segment searchを適用するように設定して、match_allクエリを実行してみます。

PUT /nyc_taxis/_settings
{
  "index": {
    "search": {
      "concurrent_segment_search": {
        "mode": "all"
      }
    }
  }
}
GET /nyc_taxis/_search
{
  "profile": true,
  "size": 0,
  "query": {
    "match_all": {}
  }
}

プロファイル情報からConcurrent segment searchが適用されていることがわかります。
一方でクエリの実行時間に大きな変化はなく、適用しない場合が2ミリ秒であったのに対し、適用した場合は3ミリ秒でした。

クリックすると展開されます(実行結果のプロファイル情報全体)

{
  "took": 3,
  "timed_out": false,
  "terminated_early": true,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": null,
    "hits": []
  },
  "profile": {
    "shards": [
      {
        "id": "[r15K5m_LQkihEi6R7pUMFA][nyc_taxis][0]",
        "inbound_network_time_in_millis": 0,
        "outbound_network_time_in_millis": 0,
        "searches": [
          {
            "query": [
              {
                "type": "ConstantScoreQuery",
                "description": "ConstantScore(*:*)",
                "time_in_nanos": 13557,
                "max_slice_time_in_nanos": 0,
                "min_slice_time_in_nanos": 0,
                "avg_slice_time_in_nanos": 0,
                "breakdown": {
                  "max_match": 0,
                  "set_min_competitive_score_count": 0,
                  "match_count": 0,
                  "avg_score_count": 0,
                  "shallow_advance_count": 0,
                  "next_doc": 0,
                  "min_build_scorer": 0,
                  "score_count": 0,
                  "compute_max_score_count": 0,
                  "advance": 0,
                  "min_advance": 0,
                  "score": 0,
                  "min_set_min_competitive_score": 0,
                  "avg_set_min_competitive_score_count": 0,
                  "min_match_count": 0,
                  "avg_score": 0,
                  "max_next_doc_count": 0,
                  "avg_shallow_advance": 0,
                  "max_compute_max_score_count": 0,
                  "max_shallow_advance_count": 0,
                  "set_min_competitive_score": 0,
                  "min_build_scorer_count": 0,
                  "next_doc_count": 0,
                  "avg_next_doc": 0,
                  "min_match": 0,
                  "compute_max_score": 0,
                  "max_build_scorer": 0,
                  "min_set_min_competitive_score_count": 0,
                  "avg_match_count": 0,
                  "avg_advance": 0,
                  "build_scorer_count": 0,
                  "avg_build_scorer_count": 0,
                  "min_next_doc_count": 0,
                  "avg_match": 0,
                  "max_score_count": 0,
                  "min_shallow_advance_count": 0,
                  "avg_compute_max_score": 0,
                  "max_advance": 0,
                  "avg_shallow_advance_count": 0,
                  "avg_set_min_competitive_score": 0,
                  "avg_compute_max_score_count": 0,
                  "avg_build_scorer": 0,
                  "max_set_min_competitive_score_count": 0,
                  "advance_count": 0,
                  "max_build_scorer_count": 0,
                  "shallow_advance": 0,
                  "max_match_count": 0,
                  "min_compute_max_score": 0,
                  "create_weight_count": 1,
                  "build_scorer": 0,
                  "max_compute_max_score": 0,
                  "max_set_min_competitive_score": 0,
                  "match": 0,
                  "min_shallow_advance": 0,
                  "min_next_doc": 0,
                  "avg_advance_count": 0,
                  "max_shallow_advance": 0,
                  "max_advance_count": 0,
                  "min_score": 0,
                  "max_next_doc": 0,
                  "create_weight": 13557,
                  "avg_next_doc_count": 0,
                  "max_score": 0,
                  "min_compute_max_score_count": 0,
                  "min_score_count": 0,
                  "min_advance_count": 0
                },
                "children": [
                  {
                    "type": "MatchAllDocsQuery",
                    "description": "*:*",
                    "time_in_nanos": 2130,
                    "max_slice_time_in_nanos": 0,
                    "min_slice_time_in_nanos": 0,
                    "avg_slice_time_in_nanos": 0,
                    "breakdown": {
                      "max_match": 0,
                      "set_min_competitive_score_count": 0,
                      "match_count": 0,
                      "avg_score_count": 0,
                      "shallow_advance_count": 0,
                      "next_doc": 0,
                      "min_build_scorer": 0,
                      "score_count": 0,
                      "compute_max_score_count": 0,
                      "advance": 0,
                      "min_advance": 0,
                      "score": 0,
                      "min_set_min_competitive_score": 0,
                      "avg_set_min_competitive_score_count": 0,
                      "min_match_count": 0,
                      "avg_score": 0,
                      "max_next_doc_count": 0,
                      "avg_shallow_advance": 0,
                      "max_compute_max_score_count": 0,
                      "max_shallow_advance_count": 0,
                      "set_min_competitive_score": 0,
                      "min_build_scorer_count": 0,
                      "next_doc_count": 0,
                      "avg_next_doc": 0,
                      "min_match": 0,
                      "compute_max_score": 0,
                      "max_build_scorer": 0,
                      "min_set_min_competitive_score_count": 0,
                      "avg_match_count": 0,
                      "avg_advance": 0,
                      "build_scorer_count": 0,
                      "avg_build_scorer_count": 0,
                      "min_next_doc_count": 0,
                      "avg_match": 0,
                      "max_score_count": 0,
                      "min_shallow_advance_count": 0,
                      "avg_compute_max_score": 0,
                      "max_advance": 0,
                      "avg_shallow_advance_count": 0,
                      "avg_set_min_competitive_score": 0,
                      "avg_compute_max_score_count": 0,
                      "avg_build_scorer": 0,
                      "max_set_min_competitive_score_count": 0,
                      "advance_count": 0,
                      "max_build_scorer_count": 0,
                      "shallow_advance": 0,
                      "max_match_count": 0,
                      "min_compute_max_score": 0,
                      "create_weight_count": 1,
                      "build_scorer": 0,
                      "max_compute_max_score": 0,
                      "max_set_min_competitive_score": 0,
                      "match": 0,
                      "min_shallow_advance": 0,
                      "min_next_doc": 0,
                      "avg_advance_count": 0,
                      "max_shallow_advance": 0,
                      "max_advance_count": 0,
                      "min_score": 0,
                      "max_next_doc": 0,
                      "create_weight": 2130,
                      "avg_next_doc_count": 0,
                      "max_score": 0,
                      "min_compute_max_score_count": 0,
                      "min_score_count": 0,
                      "min_advance_count": 0
                    }
                  }
                ]
              }
            ],
            "rewrite_time": 7206,
            "collector": [
              {
                "name": "EarlyTerminatingCollectorManager",
                "reason": "search_count",
                "time_in_nanos": 83375,
                "reduce_time_in_nanos": 10123,
                "max_slice_time_in_nanos": 8196,
                "min_slice_time_in_nanos": 763,
                "avg_slice_time_in_nanos": 2645,
                "slice_count": 4
              }
            ]
          }
        ],
        "aggregations": []
      }
    ]
  }
}

以上のようにして、Concurrent segment searchの適用状況を確認すると共に、その効果の一端を体験することができました。

制約と考慮事項

Concurrent Segment Searchの制約事項と考慮点をまとめます。

対応していない集約処理

以下の集約処理は並行検索モデルをサポートしていません。

  • Parent aggregations(結合フィールド): 親子関係を持つドキュメント間での集約処理
  • Sampler/Diversified sampler aggregations: サンプリングベースの集約処理

terminate_afterパラメータの制限

terminate_afterパラメータを使用した検索リクエストでは、Concurrent Segment Searchは自動的に無効化されます。このパラメータは指定した数のマッチングドキュメントが見つかった時点で検索を終了させるものですが、並行処理との組み合わせは以下の理由で制限されています。

  • 小さなterminate_after値では元々高速に完了するため、並行化のメリットが限定的
  • track_total_hitssizeパラメータとの組み合わせで期待される動作が複雑化
  • 並行・非並行リクエスト間での一貫性のある結果を保証するため

ソート処理への影響

ソート最適化機能は、セグメントの最小値・最大値に基づいて不要なセグメントを除外できます。しかし、データの分布によってはパフォーマンスへの影響が異なります。

  • 上位の値が最初のセグメントに集中している場合、他のセグメントが除外されても並行処理のオーバーヘッドによりレイテンシが増加する可能性がある
  • 逆に、上位の値が後方のセグメントに存在する場合は、並行処理によるパフォーマンス向上が期待できる

Terms集約の精度

Terms集約ではdoc_count_error_upper_boundでドキュメントカウントの誤差を返しますが、Concurrent Segment Searchではshard_sizeパラメータがセグメントスライスレベルで適用されるため、追加の誤差が発生する可能性があります。

Amazon OpenSearch Serviceにおける挙動

これまでにも触れた通り、Amazon OpenSearch Serviceでは、Concurrent segment searchの適用に関する挙動がOSSOpenSearchとは異なる点があります。以下にAmazon OpenSearch Service固有の挙動をまとめます。

  • OpenSearch version 2.17以降の新規作成ドメインでは、デフォルトでsearch.concurrent_segment_search.mode=autoが設定されます。これは、2xl以上のインスタンスタイプのノードに適用されます。
  • OpenSearch version 2.17以降の既存ドメインをアップグレードする場合、デフォルトでsearch.concurrent_segment_search.mode=autoが設定されます。これは、2xl以上のインスタンスタイプのノードに適用され、かつ過去1週間のクラスタ全体のCPU使用率が45%未満である場合に適用されます。
  • デフォルトでは、search.concurrent.max_slice_count=2が設定されます。

詳細は、Concurrent segment search in Amazon OpenSearch Serviceを参照してください。

Concurrent segment searchによるパフォーマンスの向上

Exploring concurrent segment search performanceには、opensearch-benchmark-workloadsを使用したさまざまなワークロードとクラスター構成でのパフォーマンス検証結果が詳細に報告されています。総じて、アグリゲーションなどの長時間実行されるクエリにおいて、Concurrent segment searchを使用することで顕著なレイテンシ改善が観測されています。以下は、ブログ記事からの要点です。

  • Concurrent segment searchを使用することで、CPU集約的で長時間実行されるクエリで顕著なレイテンシ改善が観測された。
  • 並列数(スライス数)が2〜4程度の段階では少量のCPUリソースの増加で大きな性能向上が得られた。
  • スライス数の増加に伴ってCPU使用率やJVMヒープの使用率が増加し、性能向上の効果は頭打ちになった。これは、並列処理間の重複作業や、検索結果の集約処理に伴うオーバーヘッドが影響していると考えられる。
  • match_allのような単純なクエリでは、Concurrent segment searchの効果は限定的で、場合によっては並列化のオーバーヘッドにより若干の性能低下も見られた。
  • 並列数を増やすことでCPU使用率は増加し、性能は同時実行クライアントが使用するCPUリソースなどの要因に依存する。

以下にブログで紹介されている検証結果の一部を示します。詳細はぜひ元記事を参照してください。


サイジングの考え方

クラスターのサイジングを行う際、Concurrent Segment Searchを考慮しない場合、単体の検索クエリの性能向上の観点ではプライマリーシャード数を超える論理CPUコア数を持つデータノードは、単一の検索クエリに対して余剰の論理CPUコアを持つことが前提となります。
一方でConcurrent segment searchを利用する場合、マルチスレッドで検索処理を並列化できるため、より多くの論理CPUコアを有効活用できることになります。ただし、検索の並列数はシャードを構成するセグメント数に依存するため、運用の過程でセグメント数が増減していく点を考慮すると、スケールの度合いを事前に予測するのは難しい側面があります。また、並列で検索を行う上ではシャードあたりのセグメント数が2つ以上存在することが前提となるため、OpenSearchのセグメントマージポリシーではデフォルトで5GBを目標にセグメントがマージされる点を勘案すると、シャードあたりのサイズが少なくとも5GB以上となり2つ以上のセグメントが定常的に存在するユースケースで効果が期待できると考えられます。加えて、並列処理の効果は検索クエリの内容によっても変動します。
こうしたことから、Concurrent segment searchを前提にサイジングと性能設計を行うというよりは、まずはデフォルト設定により自動適用される範囲で運用し、個別のチューニングが必要な場合に明示的な適用やスライス数の調整を行うといったアプローチが現実的であると考えられます。
OpenSearch公式ドキュメントでは、スライス数を2に設定(デフォルト設定に近い値)し、パフォーマンスを測定することから始めることを推奨しています。ワークロードがCPUリソースの50%以上を消費している場合、クラスターのスケールアップを検討するか、Concurrent segment searchを無効化すべきかもしれません。逆に、CPUリソースに余裕がある場合は、スライス数を増やすことを検討する余地があります。また、同時実行クライアント数が多い場合はCPU使用率が高くなる傾向があるため、スライス数を減らすことを検討した方が良い可能性があります。
OpenSearch 3.0にアップグレードする際は、集約やkNNを含む検索でConcurrent segment searchがデフォルトで自動適用されるため、CPU使用率の増加に注意が必要です。Concurrent segment search適用前の時点で25%以上のCPU使用率がある場合は、クラスターのリソースをスケールアップするか、Concurrent segment searchを無効化することを検討する必要があります。
これらに加えて、クラスターのサイジングは、Concurrent segment searchの効果だけでなく、インデックスの書き込みや更新、同時実行クライアント数、可用性と冗長性の確保など、さまざまな要因を総合的に考慮して行うべきです。

まとめ

OpenSearchのConcurrent segment searchは、検索性能を向上させるための強力な機能です。セグメントをスライスと呼ばれる単位で並列に処理することで、特に集約やkNN検索などの長時間実行されるクエリで顕著なレイテンシ改善が期待できます。
OpenSearch 3.0以降や、Amazon OpenSearch Service 2.17以降では、デフォルトで一部のクエリに自動適用されるため、ユーザーは特別な設定を行わなくてもConcurrent segment searchの恩恵を受けることができます。また、Concurrent segment searchの適用範囲やスライス数を調整することで、特定のユースケースに最適化することも可能です。
ただし、Concurrent segment searchの効果を最大限に引き出すためには、スライス数の調整やクエリの内容に応じた設定が重要です。また、Concurrent segment searchを利用することでCPUやJVMヒープの使用率が増加する可能性があるため、クラスターのサイジングやパフォーマンス監視も重要な要素となります。

参考


  1. OpenSearchのインデックスとは異なる点に注意
  2. OpenSearchの論理CPUコアはindexingやコーディネータノードの処理にも利用されるため、それらも考慮したサイジングが必要です。
  3. ただし、シャード数の増加は分散処理によるオーバーヘッドやリソースの消費を増加させるため、シャード数を増やしたからといって必ずしも検索性能が向上するわけではありません。