Browse Source

Add profiling and documentation for dfs phase (#90536)

Adds profiling statistics for the dfs phase, and adds documentation for both the dfs phase profiling 
and kNN profiling.

Closes #89713
Jack Conradson 3 years ago
parent
commit
8b0d0716d1

+ 6 - 0
docs/changelog/90536.yaml

@@ -0,0 +1,6 @@
+pr: 90536
+summary: Add profiling and documentation for dfs phase
+area: Search
+type: enhancement
+issues:
+ - 89713

+ 220 - 4
docs/reference/search/profile.asciidoc

@@ -955,8 +955,7 @@ overall time, the breakdown is inclusive of all children times.
 [[profiling-fetch]]
 ===== Profiling Fetch
 
-
-All shards the fetched documents will have a `fetch` section in the profile.
+All shards that fetched documents will have a `fetch` section in the profile.
 Let's execute a small search and have a look a the fetch profile:
 
 [source,console]
@@ -1047,6 +1046,224 @@ per hit. In that case the true cost of `_source` phase is hidden in the
 loading stored fields by setting
 `"_source": false, "stored_fields": ["_none_"]`.
 
+[[profiling-dfs]]
+===== Profiling DFS
+
+The DFS phase runs before the query phase to collect global information
+relevant to the query. It's currently used in two cases:
+
+. When the `search_type` is set to
+<<profiling-dfs-statistics, `dfs_query_then_fetch`>> and the index has
+multiple shards.
+. When the search request contains a <<profiling-knn-search, knn section>>.
+
+Both of these cases can be profiled by setting `profile` to `true` as
+part of the search request.
+
+[[profiling-dfs-statistics]]
+====== Profiling DFS Statistics
+
+When the `search_type` is set to `dfs_query_then_fetch` and the index
+has multiple shards, the dfs phase collects term statistics to improve
+the relevance of search results.
+
+The following is an example of setting `profile` to `true` on a search
+that uses `dfs_query_then_fetch`:
+
+Let's first setup an index with multiple shards and index
+a pair of documents with different values on a `keyword` field.
+
+[source,console,id=profile_dfs]
+--------------------------------------------------
+PUT my-dfs-index
+{
+  "settings": {
+    "number_of_shards": 2, <1>
+    "number_of_replicas": 1
+  },
+  "mappings": {
+      "properties": {
+        "my-keyword": { "type": "keyword" }
+      }
+    }
+}
+
+POST my-dfs-index/_bulk?refresh=true
+{ "index" : { "_id" : "1" } }
+{ "my-keyword" : "a" }
+{ "index" : { "_id" : "2" } }
+{ "my-keyword" : "b" }
+--------------------------------------------------
+<1> The `my-dfs-index` is created with multiple shards.
+
+With an index setup, we can now profile the dfs phase of a
+search query. For this example we use a term query.
+
+[source,console]
+--------------------------------------------------
+GET /my-dfs-index/_search?search_type=dfs_query_then_fetch&pretty&size=0 <1>
+{
+  "profile": true, <2>
+  "query": {
+    "term": {
+      "my-keyword": {
+        "value": "a"
+      }
+    }
+  }
+}
+--------------------------------------------------
+// TEST[continued]
+<1> The `search_type` url parameter is set to `dfs_query_then_fetch` to
+ensure the dfs phase is run.
+<2> The `profile` parameter is set to `true`.
+
+In the response, we see a profile which includes a `dfs` section
+for each shard along with profile output for the rest of the search phases.
+One of the `dfs` sections for a shard looks like the following:
+
+[source,console-result]
+--------------------------------------------------
+"dfs" : {
+    "statistics" : {
+        "type" : "statistics",
+        "description" : "collect term statistics",
+        "time_in_nanos" : 236955,
+        "breakdown" : {
+            "term_statistics" : 4815,
+            "collection_statistics" : 27081,
+            "collection_statistics_count" : 1,
+            "create_weight" : 153278,
+            "term_statistics_count" : 1,
+            "rewrite_count" : 0,
+            "create_weight_count" : 1,
+            "rewrite" : 0
+        }
+    }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/^/{\n"took": $body.took,\n"timed_out": $body.timed_out,\n"_shards": $body._shards,\n"hits": $body.hits,\n"profile": {\n"shards": [ "$body.$_path", {\n"id": "$body.$_path",\n/]
+// TESTRESPONSE[s/}$/}, "aggregations": [], "searches": $body.$_path}]}}/]
+// TESTRESPONSE[s/(\-)?[0-9]+/ $body.$_path/]
+
+In the `dfs.statistics` portion of this response we see a `time_in_nanos`
+which is the total time it took to collect term statistics for this
+shard along with a further breakdown of the individual parts.
+
+[[profiling-knn-search]]
+====== Profiling kNN Search
+
+A <<approximate-knn, k-nearest neighbor (kNN)>> search runs during
+the dfs phase.
+
+The following is an example of setting `profile` to `true` on a search
+that has a `knn` section:
+
+Let's first setup an index with several dense vectors.
+
+[source,console,id=profile_knn]
+--------------------------------------------------
+PUT my-knn-index
+{
+  "mappings": {
+    "properties": {
+      "my-vector": {
+        "type": "dense_vector",
+        "dims": 3,
+        "index": true,
+        "similarity": "l2_norm"
+      }
+    }
+  }
+}
+
+POST my-knn-index/_bulk?refresh=true
+{ "index": { "_id": "1" } }
+{ "my-vector": [1, 5, -20] }
+{ "index": { "_id": "2" } }
+{ "my-vector": [42, 8, -15] }
+{ "index": { "_id": "3" } }
+{ "my-vector": [15, 11, 23] }
+--------------------------------------------------
+
+With an index setup, we can now profile a kNN search query.
+
+[source,console]
+--------------------------------------------------
+POST my-knn-index/_search
+{
+  "profile": true, <1>
+  "knn": {
+    "field": "my-vector",
+    "query_vector": [-5, 9, -12],
+    "k": 3,
+    "num_candidates": 100
+  }
+}
+--------------------------------------------------
+// TEST[continued]
+
+<1> The `profile` parameter is set to `true`.
+
+In the response, we see a profile which includes a `knn` section
+as part of the `dfs` section for each shard along with profile output for the
+rest of the search phases.
+
+One of the `dfs.knn` sections for a shard looks like the following:
+
+[source,js]
+--------------------------------------------------
+"dfs" : {
+    "knn" : {
+        "query" : [
+            {
+                "type" : "DocAndScoreQuery",
+                "description" : "DocAndScore[100]",
+                "time_in_nanos" : 444414,
+                "breakdown" : {
+                  "set_min_competitive_score_count" : 0,
+                  "match_count" : 0,
+                  "shallow_advance_count" : 0,
+                  "set_min_competitive_score" : 0,
+                  "next_doc" : 1688,
+                  "match" : 0,
+                  "next_doc_count" : 3,
+                  "score_count" : 3,
+                  "compute_max_score_count" : 0,
+                  "compute_max_score" : 0,
+                  "advance" : 4153,
+                  "advance_count" : 1,
+                  "score" : 2099,
+                  "build_scorer_count" : 2,
+                  "create_weight" : 128879,
+                  "shallow_advance" : 0,
+                  "create_weight_count" : 1,
+                  "build_scorer" : 307595
+                }
+            }
+        ],
+        "rewrite_time" : 1275732,
+        "collector" : [
+            {
+                "name" : "SimpleTopScoreDocCollector",
+                "reason" : "search_top_hits",
+                "time_in_nanos" : 17163
+            }
+        ]
+    }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/^/{\n"took": $body.took,\n"timed_out": $body.timed_out,\n"_shards": $body._shards,\n"hits": $body.hits,\n"profile": {\n"shards": [ {\n"id": "$body.$_path",\n/]
+// TESTRESPONSE[s/}$/}, "aggregations": [], "searches": $body.$_path, "fetch": $body.$_path}]}}/]
+// TESTRESPONSE[s/ (\-)?[0-9]+/ $body.$_path/]
+// TESTRESPONSE[s/"dfs" : \{/"dfs" : {"statistics": $body.$_path,/]
+
+In the `dfs.knn` portion of the response we can see the output
+the of timings for <<query-section, query>>, <<rewrite-section, rewrite>>,
+and <<collectors-section, collector>>. Unlike many other queries, kNN
+search does the bulk of the work during the query rewrite. This means
+`rewrite_time` represents the time spent on kNN search.
+
 [[profiling-considerations]]
 ===== Profiling Considerations
 
@@ -1069,8 +1286,7 @@ have a drastic effect compared to other components in the profiled query.
 - Profiling also does not account for time spent in the queue, merging shard
 responses on the coordinating node, or additional work such as building global
 ordinals (an internal data structure used to speed up search).
-- Profiling statistics are currently not available for suggestions,
-highlighting, `dfs_query_then_fetch`.
+- Profiling statistics are currently not available for suggestions.
 - Profiling of the reduce phase of aggregation is currently not available.
 - The Profiler is instrumenting internals that can change from version to
 version. The resulting json should be considered mostly unstable, especially

+ 124 - 3
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search/370_profile.yml

@@ -229,7 +229,7 @@ dfs knn vector profiling:
   - gt: { profile.shards.0.dfs.knn.collector.0.time_in_nanos: 0 }
 
 ---
-dfs without knn vector profiling:
+dfs profile for search with dfs_query_then_fetch:
   - skip:
       version: ' - 8.5.99'
       reason: dfs profiling implemented in 8.6.0
@@ -239,11 +239,49 @@ dfs without knn vector profiling:
         index: keywords
         body:
           settings:
-            index.number_of_shards: 1
+            index.number_of_shards: 2
           mappings:
             properties:
               keyword:
                 type: "keyword"
+
+  # test with no indexed documents
+  # expect both shards to have dfs profiles
+  - do:
+      search:
+        index: keywords
+        search_type: dfs_query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "a"
+
+  - match: { hits.total.value: 0 }
+  - match: { profile.shards.0.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.0.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.0.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.0.dfs.statistics.breakdown
+  - match: { profile.shards.1.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.1.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.1.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.1.dfs.statistics.breakdown
+
+  # expect no shards to have dfs profiles
+  - do:
+      search:
+        index: keywords
+        search_type: query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "a"
+
+  - match: { hits.total.value: 0 }
+  - is_false: profile.shards.0.dfs
+
+  # test with two indexed documents
   - do:
       index:
         index: keywords
@@ -252,6 +290,15 @@ dfs without knn vector profiling:
         body:
           keyword: "a"
 
+  - do:
+      index:
+        index: keywords
+        id: "2"
+        refresh: true
+        body:
+          keyword: "b"
+
+  # expect both shards to have dfs profiles
   - do:
       search:
         index: keywords
@@ -263,8 +310,56 @@ dfs without knn vector profiling:
               keyword: "a"
 
   - match: { hits.total.value: 1 }
-  - is_false: profile.shards.0.dfs
+  - match: { profile.shards.0.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.0.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.0.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.0.dfs.statistics.breakdown
+  - match: { profile.shards.1.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.1.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.1.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.1.dfs.statistics.breakdown
+
+  - do:
+      search:
+        index: keywords
+        search_type: dfs_query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "b"
+
+  - match: { hits.total.value: 1 }
+  - match: { profile.shards.0.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.0.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.0.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.0.dfs.statistics.breakdown
+  - match: { profile.shards.1.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.1.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.1.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.1.dfs.statistics.breakdown
 
+  - do:
+      search:
+        index: keywords
+        search_type: dfs_query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "c"
+
+  - match: { hits.total.value: 0 }
+  - match: { profile.shards.0.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.0.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.0.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.0.dfs.statistics.breakdown
+  - match: { profile.shards.1.dfs.statistics.type: "statistics" }
+  - match: { profile.shards.1.dfs.statistics.description: "collect term statistics" }
+  - gte: { profile.shards.1.dfs.statistics.time_in_nanos: 0 }
+  - is_true: profile.shards.1.dfs.statistics.breakdown
+
+  # expect no shards to have dfs profiles
   - do:
       search:
         index: keywords
@@ -277,3 +372,29 @@ dfs without knn vector profiling:
 
   - match: { hits.total.value: 1 }
   - is_false: profile.shards.0.dfs
+
+  - do:
+      search:
+        index: keywords
+        search_type: query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "b"
+
+  - match: { hits.total.value: 1 }
+  - is_false: profile.shards.0.dfs
+
+  - do:
+      search:
+        index: keywords
+        search_type: query_then_fetch
+        body:
+          profile: true
+          query:
+            term:
+              keyword: "c"
+
+  - match: { hits.total.value: 0 }
+  - is_false: profile.shards.0.dfs

+ 1 - 0
server/src/main/java/module-info.java

@@ -329,6 +329,7 @@ module org.elasticsearch.server {
     exports org.elasticsearch.search.lookup;
     exports org.elasticsearch.search.profile;
     exports org.elasticsearch.search.profile.aggregation;
+    exports org.elasticsearch.search.profile.dfs;
     exports org.elasticsearch.search.profile.query;
     exports org.elasticsearch.search.query;
     exports org.elasticsearch.search.rescore;

+ 84 - 17
server/src/main/java/org/elasticsearch/search/dfs/DfsPhase.java

@@ -16,9 +16,12 @@ import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreMode;
 import org.apache.lucene.search.TermStatistics;
 import org.apache.lucene.search.TopScoreDocCollector;
+import org.apache.lucene.search.Weight;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.search.builder.SearchSourceBuilder;
 import org.elasticsearch.search.internal.SearchContext;
+import org.elasticsearch.search.profile.dfs.DfsProfiler;
+import org.elasticsearch.search.profile.dfs.DfsTimingType;
 import org.elasticsearch.search.profile.query.CollectorResult;
 import org.elasticsearch.search.profile.query.InternalProfileCollector;
 import org.elasticsearch.search.rescore.RescoreContext;
@@ -44,12 +47,18 @@ public class DfsPhase {
         try {
             collectStatistics(context);
             executeKnnVectorQuery(context);
+
+            if (context.getProfilers() != null) {
+                context.dfsResult().profileResult(context.getProfilers().getDfsProfiler().buildDfsPhaseResults());
+            }
         } catch (Exception e) {
             throw new DfsPhaseExecutionException(context.shardTarget(), "Exception during dfs phase", e);
         }
     }
 
     private void collectStatistics(SearchContext context) throws IOException {
+        final DfsProfiler profiler = context.getProfilers() == null ? null : context.getProfilers().getDfsProfiler();
+
         Map<String, CollectionStatistics> fieldStatistics = new HashMap<>();
         Map<Term, TermStatistics> stats = new HashMap<>();
 
@@ -59,11 +68,22 @@ public class DfsPhase {
                 if (context.isCancelled()) {
                     throw new TaskCancelledException("cancelled");
                 }
-                TermStatistics ts = super.termStatistics(term, docFreq, totalTermFreq);
-                if (ts != null) {
-                    stats.put(term, ts);
+
+                if (profiler != null) {
+                    profiler.startTimer(DfsTimingType.TERM_STATISTICS);
+                }
+
+                try {
+                    TermStatistics ts = super.termStatistics(term, docFreq, totalTermFreq);
+                    if (ts != null) {
+                        stats.put(term, ts);
+                    }
+                    return ts;
+                } finally {
+                    if (profiler != null) {
+                        profiler.stopTimer(DfsTimingType.TERM_STATISTICS);
+                    }
                 }
-                return ts;
             }
 
             @Override
@@ -71,18 +91,69 @@ public class DfsPhase {
                 if (context.isCancelled()) {
                     throw new TaskCancelledException("cancelled");
                 }
-                CollectionStatistics cs = super.collectionStatistics(field);
-                if (cs != null) {
-                    fieldStatistics.put(field, cs);
+
+                if (profiler != null) {
+                    profiler.startTimer(DfsTimingType.COLLECTION_STATISTICS);
+                }
+
+                try {
+                    CollectionStatistics cs = super.collectionStatistics(field);
+                    if (cs != null) {
+                        fieldStatistics.put(field, cs);
+                    }
+                    return cs;
+                } finally {
+                    if (profiler != null) {
+                        profiler.stopTimer(DfsTimingType.COLLECTION_STATISTICS);
+                    }
+                }
+            }
+
+            @Override
+            public Weight createWeight(Query query, ScoreMode scoreMode, float boost) throws IOException {
+                if (profiler != null) {
+                    profiler.startTimer(DfsTimingType.CREATE_WEIGHT);
+                }
+
+                try {
+                    return super.createWeight(query, scoreMode, boost);
+                } finally {
+                    if (profiler != null) {
+                        profiler.stopTimer(DfsTimingType.CREATE_WEIGHT);
+                    }
+                }
+            }
+
+            @Override
+            public Query rewrite(Query original) throws IOException {
+                if (profiler != null) {
+                    profiler.startTimer(DfsTimingType.REWRITE);
+                }
+
+                try {
+                    return super.rewrite(original);
+                } finally {
+                    if (profiler != null) {
+                        profiler.stopTimer(DfsTimingType.REWRITE);
+                    }
                 }
-                return cs;
             }
         };
 
-        searcher.createWeight(context.rewrittenQuery(), ScoreMode.COMPLETE, 1);
-        for (RescoreContext rescoreContext : context.rescore()) {
-            for (Query query : rescoreContext.getQueries()) {
-                searcher.createWeight(context.searcher().rewrite(query), ScoreMode.COMPLETE, 1);
+        if (profiler != null) {
+            profiler.start();
+        }
+
+        try {
+            searcher.createWeight(context.rewrittenQuery(), ScoreMode.COMPLETE, 1);
+            for (RescoreContext rescoreContext : context.rescore()) {
+                for (Query query : rescoreContext.getQueries()) {
+                    searcher.createWeight(searcher.rewrite(query), ScoreMode.COMPLETE, 1);
+                }
+            }
+        } finally {
+            if (profiler != null) {
+                profiler.stop();
             }
         }
 
@@ -122,16 +193,12 @@ public class DfsPhase {
                 CollectorResult.REASON_SEARCH_TOP_HITS,
                 List.of()
             );
-            context.getProfilers().getCurrentQueryProfiler().setCollector(ipc);
+            context.getProfilers().getDfsProfiler().setCollector(ipc);
             collector = ipc;
         }
 
         context.searcher().search(query, collector);
         DfsKnnResults knnResults = new DfsKnnResults(topScoreDocCollector.topDocs().scoreDocs);
         context.dfsResult().knnResults(knnResults);
-
-        if (context.getProfilers() != null) {
-            context.dfsResult().profileResult(context.getProfilers().buildDfsPhaseResults());
-        }
     }
 }

+ 12 - 12
server/src/main/java/org/elasticsearch/search/profile/Profilers.java

@@ -12,6 +12,7 @@ import org.elasticsearch.search.fetch.FetchProfiler;
 import org.elasticsearch.search.internal.ContextIndexSearcher;
 import org.elasticsearch.search.profile.aggregation.AggregationProfileShardResult;
 import org.elasticsearch.search.profile.aggregation.AggregationProfiler;
+import org.elasticsearch.search.profile.dfs.DfsProfiler;
 import org.elasticsearch.search.profile.query.QueryProfileShardResult;
 import org.elasticsearch.search.profile.query.QueryProfiler;
 
@@ -25,6 +26,7 @@ public final class Profilers {
     private final ContextIndexSearcher searcher;
     private final List<QueryProfiler> queryProfilers = new ArrayList<>();
     private final AggregationProfiler aggProfiler = new AggregationProfiler();
+    private DfsProfiler dfsProfiler;
 
     public Profilers(ContextIndexSearcher searcher) {
         this.searcher = searcher;
@@ -60,23 +62,21 @@ public final class Profilers {
     }
 
     /**
-     * Build a profiler for the fetch phase.
+     * Build a profiler for the dfs phase or get the existing one.
      */
-    public static FetchProfiler startProfilingFetchPhase() {
-        return new FetchProfiler();
+    public DfsProfiler getDfsProfiler() {
+        if (dfsProfiler == null) {
+            dfsProfiler = new DfsProfiler(getCurrentQueryProfiler());
+        }
+
+        return dfsProfiler;
     }
 
     /**
-     * Build the results for the dfs phase.
+     * Build a profiler for the fetch phase.
      */
-    public SearchProfileDfsPhaseResult buildDfsPhaseResults() {
-        QueryProfiler queryProfiler = getCurrentQueryProfiler();
-        QueryProfileShardResult queryProfileShardResult = new QueryProfileShardResult(
-            queryProfiler.getTree(),
-            queryProfiler.getRewriteTime(),
-            queryProfiler.getCollector()
-        );
-        return new SearchProfileDfsPhaseResult(queryProfileShardResult);
+    public static FetchProfiler startProfilingFetchPhase() {
+        return new FetchProfiler();
     }
 
     /**

+ 23 - 3
server/src/main/java/org/elasticsearch/search/profile/SearchProfileDfsPhaseResult.java

@@ -27,22 +27,27 @@ import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstr
 
 public class SearchProfileDfsPhaseResult implements Writeable, ToXContentObject {
 
+    private final ProfileResult dfsShardResult;
     private final QueryProfileShardResult queryProfileShardResult;
 
     @ParserConstructor
-    public SearchProfileDfsPhaseResult(@Nullable QueryProfileShardResult queryProfileShardResult) {
+    public SearchProfileDfsPhaseResult(@Nullable ProfileResult dfsShardResult, @Nullable QueryProfileShardResult queryProfileShardResult) {
+        this.dfsShardResult = dfsShardResult;
         this.queryProfileShardResult = queryProfileShardResult;
     }
 
     public SearchProfileDfsPhaseResult(StreamInput in) throws IOException {
+        dfsShardResult = in.readOptionalWriteable(ProfileResult::new);
         queryProfileShardResult = in.readOptionalWriteable(QueryProfileShardResult::new);
     }
 
     @Override
     public void writeTo(StreamOutput out) throws IOException {
+        out.writeOptionalWriteable(dfsShardResult);
         out.writeOptionalWriteable(queryProfileShardResult);
     }
 
+    private static final ParseField STATISTICS = new ParseField("statistics");
     private static final ParseField KNN = new ParseField("knn");
     private static final InstantiatingObjectParser<SearchProfileDfsPhaseResult, Void> PARSER;
 
@@ -52,6 +57,7 @@ public class SearchProfileDfsPhaseResult implements Writeable, ToXContentObject
             true,
             SearchProfileDfsPhaseResult.class
         );
+        parser.declareObject(optionalConstructorArg(), (p, c) -> ProfileResult.fromXContent(p), STATISTICS);
         parser.declareObject(optionalConstructorArg(), (p, c) -> QueryProfileShardResult.fromXContent(p), KNN);
         PARSER = parser.build();
     }
@@ -63,6 +69,10 @@ public class SearchProfileDfsPhaseResult implements Writeable, ToXContentObject
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
         builder.startObject();
+        if (dfsShardResult != null) {
+            builder.field(STATISTICS.getPreferredName());
+            dfsShardResult.toXContent(builder, params);
+        }
         if (queryProfileShardResult != null) {
             builder.field(KNN.getPreferredName());
             queryProfileShardResult.toXContent(builder, params);
@@ -76,11 +86,21 @@ public class SearchProfileDfsPhaseResult implements Writeable, ToXContentObject
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         SearchProfileDfsPhaseResult that = (SearchProfileDfsPhaseResult) o;
-        return Objects.equals(queryProfileShardResult, that.queryProfileShardResult);
+        return Objects.equals(dfsShardResult, that.dfsShardResult) && Objects.equals(queryProfileShardResult, that.queryProfileShardResult);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(queryProfileShardResult);
+        return Objects.hash(dfsShardResult, queryProfileShardResult);
+    }
+
+    @Override
+    public String toString() {
+        return "SearchProfileDfsPhaseResult{"
+            + "dfsShardResult="
+            + dfsShardResult
+            + ", queryProfileShardResult="
+            + queryProfileShardResult
+            + '}';
     }
 }

+ 74 - 0
server/src/main/java/org/elasticsearch/search/profile/dfs/DfsProfiler.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.search.profile.dfs;
+
+import org.elasticsearch.search.profile.AbstractProfileBreakdown;
+import org.elasticsearch.search.profile.ProfileResult;
+import org.elasticsearch.search.profile.SearchProfileDfsPhaseResult;
+import org.elasticsearch.search.profile.query.InternalProfileCollector;
+import org.elasticsearch.search.profile.query.QueryProfileShardResult;
+import org.elasticsearch.search.profile.query.QueryProfiler;
+
+import java.util.List;
+
+/**
+ * This class collects profiling information for the dfs phase and
+ * generates a {@link ProfileResult} for the results of the timing
+ * information for statistics collection. An additional {@link QueryProfiler}
+ * is used to profile a knn vector query if one is executed.
+ */
+public class DfsProfiler extends AbstractProfileBreakdown<DfsTimingType> {
+
+    private long startTime;
+    private long totalTime;
+
+    private final QueryProfiler queryProfiler;
+    private boolean collectorSet = false;
+
+    public DfsProfiler(QueryProfiler queryProfiler) {
+        super(DfsTimingType.class);
+        this.queryProfiler = queryProfiler;
+    }
+
+    public void start() {
+        startTime = System.nanoTime();
+    }
+
+    public void stop() {
+        totalTime = System.nanoTime() - startTime;
+    }
+
+    public void startTimer(DfsTimingType dfsTimingType) {
+        getTimer(dfsTimingType).start();
+    }
+
+    public void stopTimer(DfsTimingType dfsTimingType) {
+        getTimer(dfsTimingType).stop();
+    }
+
+    public void setCollector(InternalProfileCollector collector) {
+        queryProfiler.setCollector(collector);
+        collectorSet = true;
+    }
+
+    public SearchProfileDfsPhaseResult buildDfsPhaseResults() {
+        ProfileResult dfsProfileResult = new ProfileResult(
+            "statistics",
+            "collect term statistics",
+            toBreakdownMap(),
+            toDebugMap(),
+            totalTime,
+            List.of()
+        );
+        QueryProfileShardResult queryProfileShardResult = collectorSet
+            ? new QueryProfileShardResult(queryProfiler.getTree(), queryProfiler.getRewriteTime(), queryProfiler.getCollector())
+            : null;
+        return new SearchProfileDfsPhaseResult(dfsProfileResult, queryProfileShardResult);
+    }
+}

+ 23 - 0
server/src/main/java/org/elasticsearch/search/profile/dfs/DfsTimingType.java

@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.search.profile.dfs;
+
+import java.util.Locale;
+
+public enum DfsTimingType {
+    TERM_STATISTICS,
+    COLLECTION_STATISTICS,
+    CREATE_WEIGHT,
+    REWRITE;
+
+    @Override
+    public String toString() {
+        return name().toLowerCase(Locale.ROOT);
+    }
+}

+ 0 - 1
server/src/test/java/org/elasticsearch/action/search/KnnSearchSingleNodeTests.java

@@ -56,7 +56,6 @@ public class KnnSearchSingleNodeTests extends ESSingleNodeTestCase {
         float[] queryVector = randomVector();
         KnnSearchBuilder knnSearch = new KnnSearchBuilder("vector", queryVector, 5, 50).boost(5.0f);
         SearchResponse response = client().prepareSearch("index")
-            .setProfile(true)
             .setKnnSearch(knnSearch)
             .setQuery(QueryBuilders.matchQuery("text", "goodnight"))
             .addFetchField("*")

+ 4 - 1
server/src/test/java/org/elasticsearch/search/profile/SearchProfileDfsPhaseResultTests.java

@@ -18,7 +18,10 @@ import java.io.IOException;
 public class SearchProfileDfsPhaseResultTests extends AbstractSerializingTestCase<SearchProfileDfsPhaseResult> {
 
     static SearchProfileDfsPhaseResult createTestItem() {
-        return new SearchProfileDfsPhaseResult(rarely() ? null : QueryProfileShardResultTests.createTestItem());
+        return new SearchProfileDfsPhaseResult(
+            randomBoolean() ? null : ProfileResultTests.createTestItem(1),
+            randomBoolean() ? null : QueryProfileShardResultTests.createTestItem()
+        );
     }
 
     @Override