You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by vo...@apache.org on 2025/04/25 10:06:45 UTC

(lucene) branch branch_10x updated: Logic for collecting Histogram efficiently using Point Trees (#14439)

This is an automated email from the ASF dual-hosted git repository.

voditas pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new 7a21f53eeff Logic for collecting Histogram efficiently using Point Trees (#14439)
7a21f53eeff is described below

commit 7a21f53eeff71d4b78fcfa5375b4c08124b857a6
Author: Ankit Jain <ja...@gmail.com>
AuthorDate: Fri Apr 25 15:06:46 2025 +0530

    Logic for collecting Histogram efficiently using Point Trees (#14439)
---
 lucene/CHANGES.txt                                 |   2 +
 lucene/benchmark-jmh/build.gradle                  |   1 +
 lucene/benchmark-jmh/src/java/module-info.java     |   1 +
 .../benchmark/jmh/HistogramCollectorBenchmark.java | 132 ++++++++++++
 .../facet/plain/histograms/HistogramCollector.java |  38 +++-
 .../histograms/HistogramCollectorManager.java      |   7 +-
 .../plain/histograms/PointTreeBulkCollector.java   | 235 +++++++++++++++++++++
 .../histograms/TestHistogramCollectorManager.java  |  33 +++
 8 files changed, 446 insertions(+), 3 deletions(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 9744ba4d5da..4c6e0f136f7 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -29,6 +29,8 @@ Optimizations
 ---------------------
 * GITHUB#14418: Quick exit on filter query matching no docs when rewriting knn query. (Pan Guixin)
 
+* GITHUB#14439: Efficient Histogram Collection using multi range traversal over PointTrees (Ankit Jain)
+
 * GITHUB#14268: PointInSetQuery early exit on non-matching segments. (hanbj)
 
 * GITHUB#14425: KeywordField.newSetQuery() reuses prefixed terms (Mikhail Khludnev)
diff --git a/lucene/benchmark-jmh/build.gradle b/lucene/benchmark-jmh/build.gradle
index 1751a43d7a7..c94bc69cc52 100644
--- a/lucene/benchmark-jmh/build.gradle
+++ b/lucene/benchmark-jmh/build.gradle
@@ -24,6 +24,7 @@ description = 'Lucene JMH micro-benchmarking module'
 dependencies {
   moduleImplementation project(':lucene:core')
   moduleImplementation project(':lucene:expressions')
+  moduleImplementation project(':lucene:sandbox')
 
   moduleImplementation deps.jmh.core
   annotationProcessor deps.jmh.annprocess
diff --git a/lucene/benchmark-jmh/src/java/module-info.java b/lucene/benchmark-jmh/src/java/module-info.java
index d92164cfae1..d6bfc6a411b 100644
--- a/lucene/benchmark-jmh/src/java/module-info.java
+++ b/lucene/benchmark-jmh/src/java/module-info.java
@@ -21,6 +21,7 @@ module org.apache.lucene.benchmark.jmh {
   requires jdk.unsupported;
   requires org.apache.lucene.core;
   requires org.apache.lucene.expressions;
+  requires org.apache.lucene.sandbox;
 
   exports org.apache.lucene.benchmark.jmh;
   exports org.apache.lucene.benchmark.jmh.jmh_generated;
diff --git a/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/HistogramCollectorBenchmark.java b/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/HistogramCollectorBenchmark.java
new file mode 100644
index 00000000000..c632ca7f6f8
--- /dev/null
+++ b/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/HistogramCollectorBenchmark.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.benchmark.jmh;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.sandbox.facet.plain.histograms.HistogramCollectorManager;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchAllDocsQuery;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.MMapDirectory;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Fork(value = 1, warmups = 1)
+@Warmup(iterations = 1, time = 1)
+@Measurement(iterations = 3, time = 3)
+public class HistogramCollectorBenchmark {
+  Directory dir;
+  IndexReader reader;
+  Path path;
+
+  @Setup(Level.Trial)
+  public void setup(BenchmarkParams params) throws Exception {
+    path = Files.createTempDirectory("forUtil");
+    Directory dir = MMapDirectory.open(path);
+    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
+    Random r = new Random(0);
+
+    for (int i = 0; i < params.docCount; i++) {
+      Document doc = new Document();
+      long value = r.nextInt(0, params.docCount);
+      if (params.pointEnabled) {
+        // Adding indexed point field to verify multi range collector
+        doc.add(new LongPoint("f", value));
+      } else {
+        doc.add(NumericDocValuesField.indexedField("f", value));
+      }
+      w.addDocument(doc);
+    }
+    // Force merging into single segment for testing more documents in segment scenario
+    w.forceMerge(1, true);
+    reader = DirectoryReader.open(w);
+    w.close();
+  }
+
+  @TearDown(Level.Trial)
+  public void tearDown() throws Exception {
+    reader.close();
+    if (dir != null) {
+      dir.close();
+      dir = null;
+    }
+
+    // Clean up the segment files before next run
+    if (Files.exists(path)) {
+      try (Stream<Path> walk = Files.walk(path)) {
+        walk.sorted(Comparator.reverseOrder())
+            .forEach(
+                path -> {
+                  try {
+                    Files.delete(path);
+                  } catch (
+                      @SuppressWarnings("unused")
+                      IOException unused) {
+                    // Do nothing
+                  }
+                });
+      }
+    }
+  }
+
+  @State(Scope.Benchmark)
+  public static class BenchmarkParams {
+    // Test with both point enabled and disabled
+    @Param({"true", "false"})
+    public boolean pointEnabled;
+
+    @Param({"500000", "5000000"})
+    public int docCount;
+
+    @Param({"5000", "25000"})
+    public long bucketWidth;
+  }
+
+  @Benchmark
+  public void collectHistogram(BenchmarkParams params) throws IOException {
+    IndexSearcher searcher = new IndexSearcher(reader);
+    searcher.search(
+        new MatchAllDocsQuery(), new HistogramCollectorManager("f", params.bucketWidth, 10000));
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java
index d2535d7f4da..ee57ea1e96a 100644
--- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java
+++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollector.java
@@ -17,12 +17,14 @@
 package org.apache.lucene.sandbox.facet.plain.histograms;
 
 import java.io.IOException;
+import java.util.concurrent.ConcurrentMap;
 import org.apache.lucene.index.DocValues;
 import org.apache.lucene.index.DocValuesSkipper;
 import org.apache.lucene.index.DocValuesType;
 import org.apache.lucene.index.FieldInfo;
 import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.PointValues;
 import org.apache.lucene.index.SortedNumericDocValues;
 import org.apache.lucene.internal.hppc.LongIntHashMap;
 import org.apache.lucene.search.CollectionTerminatedException;
@@ -31,6 +33,7 @@ import org.apache.lucene.search.DocIdStream;
 import org.apache.lucene.search.LeafCollector;
 import org.apache.lucene.search.Scorable;
 import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Weight;
 
 final class HistogramCollector implements Collector {
 
@@ -38,11 +41,18 @@ final class HistogramCollector implements Collector {
   private final long bucketWidth;
   private final int maxBuckets;
   private final LongIntHashMap counts;
-
-  HistogramCollector(String field, long bucketWidth, int maxBuckets) {
+  private final ConcurrentMap<LeafReaderContext, Boolean> leafBulkCollected;
+  private Weight weight;
+
+  HistogramCollector(
+      String field,
+      long bucketWidth,
+      int maxBuckets,
+      ConcurrentMap<LeafReaderContext, Boolean> leafBulkCollected) {
     this.field = field;
     this.bucketWidth = bucketWidth;
     this.maxBuckets = maxBuckets;
+    this.leafBulkCollected = leafBulkCollected;
     this.counts = new LongIntHashMap();
   }
 
@@ -53,11 +63,30 @@ final class HistogramCollector implements Collector {
       // The segment has no values, nothing to do.
       throw new CollectionTerminatedException();
     }
+
+    // We can use multi range traversal logic to collect the histogram on numeric
+    // field indexed as point for MATCH_ALL cases. In future, this can be extended
+    // for Point Range Query cases as well
+    if (weight != null && weight.count(context) == context.reader().maxDoc()) {
+      final PointValues pointValues = context.reader().getPointValues(field);
+      if (PointTreeBulkCollector.canCollectEfficiently(pointValues, bucketWidth)) {
+        // In case of intra segment concurrency, only one collector should collect
+        // documents for all the partitions to avoid duplications across collectors
+        if (leafBulkCollected.putIfAbsent(context, true) == null) {
+          PointTreeBulkCollector.collect(pointValues, bucketWidth, counts, maxBuckets);
+        }
+        // Either the collection is finished on this collector, or some other collector
+        // already started that collection, so this collector can finish early!
+        throw new CollectionTerminatedException();
+      }
+    }
+
     if (fi.getDocValuesType() != DocValuesType.NUMERIC
         && fi.getDocValuesType() != DocValuesType.SORTED_NUMERIC) {
       throw new IllegalStateException(
           "Expected numeric field, but got doc-value type: " + fi.getDocValuesType());
     }
+
     SortedNumericDocValues values = DocValues.getSortedNumeric(context.reader(), field);
     NumericDocValues singleton = DocValues.unwrapSingleton(values);
     if (singleton == null) {
@@ -296,4 +325,9 @@ final class HistogramCollector implements Collector {
               + maxBuckets);
     }
   }
+
+  @Override
+  public void setWeight(Weight weight) {
+    this.weight = weight;
+  }
 }
diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollectorManager.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollectorManager.java
index f00b8b7d5be..8eb3b23f09e 100644
--- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollectorManager.java
+++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/HistogramCollectorManager.java
@@ -19,7 +19,10 @@ package org.apache.lucene.sandbox.facet.plain.histograms;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.internal.hppc.LongIntHashMap;
 import org.apache.lucene.internal.hppc.LongIntHashMap.LongIntCursor;
 import org.apache.lucene.search.CollectorManager;
@@ -50,6 +53,7 @@ public final class HistogramCollectorManager
   private final String field;
   private final long bucketWidth;
   private final int maxBuckets;
+  private final ConcurrentMap<LeafReaderContext, Boolean> leafBulkCollected;
 
   /**
    * Compute a histogram of the distribution of the values of the given {@code field} according to
@@ -76,11 +80,12 @@ public final class HistogramCollectorManager
       throw new IllegalArgumentException("maxBuckets must be at least 1, got: " + maxBuckets);
     }
     this.maxBuckets = maxBuckets;
+    this.leafBulkCollected = new ConcurrentHashMap<>();
   }
 
   @Override
   public HistogramCollector newCollector() throws IOException {
-    return new HistogramCollector(field, bucketWidth, maxBuckets);
+    return new HistogramCollector(field, bucketWidth, maxBuckets, leafBulkCollected);
   }
 
   @Override
diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/PointTreeBulkCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/PointTreeBulkCollector.java
new file mode 100644
index 00000000000..d70e63237c0
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/plain/histograms/PointTreeBulkCollector.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.sandbox.facet.plain.histograms;
+
+import static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS;
+
+import java.io.IOException;
+import java.util.function.Function;
+import org.apache.lucene.index.PointValues;
+import org.apache.lucene.internal.hppc.LongIntHashMap;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.util.NumericUtils;
+
+/**
+ * Logic for bulk collecting histogram using multi range traversal on PointTree. If current range
+ * being collected contains the TreeNode range completely, we can completely skip traversing the
+ * TreeNodes individually
+ *
+ * @lucene.experimental
+ */
+class PointTreeBulkCollector {
+  private static Function<byte[], Long> bytesToLong(int numBytes) {
+    if (numBytes == Long.BYTES) {
+      // Used by LongPoint, DoublePoint
+      return a -> NumericUtils.sortableBytesToLong(a, 0);
+    } else if (numBytes == Integer.BYTES) {
+      // Used by IntPoint, FloatPoint, LatLonPoint, LatLonShape
+      return a -> (long) NumericUtils.sortableBytesToInt(a, 0);
+    }
+
+    return null;
+  }
+
+  static boolean canCollectEfficiently(final PointValues pointValues, final long bucketWidth)
+      throws IOException {
+    // We need pointValues.getDocCount() == pointValues.size() to count each doc only
+    // once, including docs that have two values that fall into the same bucket.
+    if (pointValues == null
+        || pointValues.getNumDimensions() != 1
+        || pointValues.getDocCount() != pointValues.size()) {
+      return false;
+    }
+
+    final Function<byte[], Long> byteToLong = bytesToLong(pointValues.getBytesPerDimension());
+    if (byteToLong == null) {
+      return false;
+    }
+
+    long leafMinBucket =
+        Math.floorDiv(byteToLong.apply(pointValues.getMinPackedValue()), bucketWidth);
+    long leafMaxBucket =
+        Math.floorDiv(byteToLong.apply(pointValues.getMaxPackedValue()), bucketWidth);
+
+    // We want that # leaf nodes is more than # buckets so that we can completely skip over
+    // some of the leaf nodes. Higher this ratio, more efficient it is than naive approach!
+    if ((pointValues.size() / 512) < (leafMaxBucket - leafMinBucket)) {
+      return false;
+    }
+
+    return true;
+  }
+
+  static void collect(
+      final PointValues pointValues,
+      final long bucketWidth,
+      final LongIntHashMap collectorCounts,
+      final int maxBuckets)
+      throws IOException {
+    final Function<byte[], Long> byteToLong = bytesToLong(pointValues.getBytesPerDimension());
+    BucketManager collector =
+        new BucketManager(
+            collectorCounts,
+            byteToLong.apply(pointValues.getMinPackedValue()),
+            bucketWidth,
+            byteToLong,
+            maxBuckets);
+    PointValues.IntersectVisitor visitor = getIntersectVisitor(collector);
+    intersectWithRanges(visitor, pointValues.getPointTree(), collector);
+    collector.finalizePreviousBucket(null);
+  }
+
+  private static void intersectWithRanges(
+      PointValues.IntersectVisitor visitor,
+      PointValues.PointTree pointTree,
+      BucketManager collector)
+      throws IOException {
+    PointValues.Relation r =
+        visitor.compare(pointTree.getMinPackedValue(), pointTree.getMaxPackedValue());
+
+    switch (r) {
+      case CELL_INSIDE_QUERY:
+        collector.countNode((int) pointTree.size());
+        break;
+      case CELL_CROSSES_QUERY:
+        if (pointTree.moveToChild()) {
+          do {
+            intersectWithRanges(visitor, pointTree, collector);
+          } while (pointTree.moveToSibling());
+          pointTree.moveToParent();
+        } else {
+          pointTree.visitDocValues(visitor);
+        }
+        break;
+      case CELL_OUTSIDE_QUERY:
+    }
+  }
+
+  private static PointValues.IntersectVisitor getIntersectVisitor(BucketManager collector) {
+    return new PointValues.IntersectVisitor() {
+      @Override
+      public void visit(int docID) {
+        // this branch should be unreachable
+        throw new UnsupportedOperationException(
+            "This IntersectVisitor does not perform any actions on a "
+                + "docID="
+                + docID
+                + " node being visited");
+      }
+
+      @Override
+      public void visit(int docID, byte[] packedValue) throws IOException {
+        if (!collector.withinUpperBound(packedValue)) {
+          collector.finalizePreviousBucket(packedValue);
+        }
+
+        if (collector.withinRange(packedValue)) {
+          collector.count();
+        }
+      }
+
+      @Override
+      public void visit(DocIdSetIterator iterator, byte[] packedValue) throws IOException {
+        if (!collector.withinUpperBound(packedValue)) {
+          collector.finalizePreviousBucket(packedValue);
+        }
+
+        if (collector.withinRange(packedValue)) {
+          for (int doc = iterator.nextDoc(); doc != NO_MORE_DOCS; doc = iterator.nextDoc()) {
+            collector.count();
+          }
+        }
+      }
+
+      @Override
+      public PointValues.Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
+        // try to find the first range that may collect values from this cell
+        if (!collector.withinUpperBound(minPackedValue)) {
+          collector.finalizePreviousBucket(minPackedValue);
+        }
+
+        // Not possible to have the CELL_OUTSIDE_QUERY, as bucket lower bound is updated
+        // while finalizing the previous bucket
+        if (collector.withinRange(minPackedValue) && collector.withinRange(maxPackedValue)) {
+          return PointValues.Relation.CELL_INSIDE_QUERY;
+        }
+        return PointValues.Relation.CELL_CROSSES_QUERY;
+      }
+    };
+  }
+
+  private static class BucketManager {
+    private final LongIntHashMap collectorCounts;
+    private int counter = 0;
+    private long startValue;
+    private long endValue;
+    private int nonZeroBuckets = 0;
+    private int maxBuckets;
+    private Function<byte[], Long> byteToLong;
+    private long bucketWidth;
+
+    public BucketManager(
+        LongIntHashMap collectorCounts,
+        long minValue,
+        long bucketWidth,
+        Function<byte[], Long> byteToLong,
+        int maxBuckets) {
+      this.collectorCounts = collectorCounts;
+      this.bucketWidth = bucketWidth;
+      this.startValue = Math.floorDiv(minValue, bucketWidth) * bucketWidth;
+      this.endValue = startValue + bucketWidth;
+      this.byteToLong = byteToLong;
+      this.maxBuckets = maxBuckets;
+    }
+
+    private void count() {
+      counter++;
+    }
+
+    private void countNode(int count) {
+      counter += count;
+    }
+
+    private void finalizePreviousBucket(byte[] packedValue) {
+      // TODO: Can counter ever be 0?
+      if (counter > 0) {
+        collectorCounts.addTo(Math.floorDiv(startValue, bucketWidth), counter);
+        if (packedValue != null) {
+          startValue = byteToLong.apply(packedValue);
+          // Align the start value with bucket width
+          startValue = Math.floorDiv(startValue, bucketWidth) * bucketWidth;
+          endValue = startValue + bucketWidth;
+        }
+        nonZeroBuckets++;
+        counter = 0;
+        HistogramCollector.checkMaxBuckets(nonZeroBuckets, maxBuckets);
+      }
+    }
+
+    private boolean withinLowerBound(byte[] value) {
+      return byteToLong.apply(value) >= startValue;
+    }
+
+    private boolean withinUpperBound(byte[] value) {
+      return byteToLong.apply(value) < endValue;
+    }
+
+    private boolean withinRange(byte[] value) {
+      return withinLowerBound(value) && withinUpperBound(value);
+    }
+  }
+}
diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/facet/plain/histograms/TestHistogramCollectorManager.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/facet/plain/histograms/TestHistogramCollectorManager.java
index a9d742d4855..ffa5e825f6f 100644
--- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/facet/plain/histograms/TestHistogramCollectorManager.java
+++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/facet/plain/histograms/TestHistogramCollectorManager.java
@@ -19,6 +19,7 @@ package org.apache.lucene.sandbox.facet.plain.histograms;
 import java.io.IOException;
 import org.apache.lucene.codecs.lucene90.Lucene90DocValuesFormat;
 import org.apache.lucene.document.Document;
+import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.NumericDocValuesField;
 import org.apache.lucene.document.SortedNumericDocValuesField;
 import org.apache.lucene.index.DirectoryReader;
@@ -115,6 +116,38 @@ public class TestHistogramCollectorManager extends LuceneTestCase {
             .setCodec(TestUtil.alwaysDocValuesFormat(new Lucene90DocValuesFormat(3))));
   }
 
+  public void testMultiRangePointTreeCollector() throws IOException {
+    Directory dir = newDirectory();
+    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
+
+    long[] values = new long[5000];
+
+    for (int i = 0; i < values.length; i++) {
+      values[i] = random().nextInt(0, 5000); // Generates a random integer
+    }
+
+    for (long value : values) {
+      Document doc = new Document();
+      // Adding indexed point field to verify multi range collector
+      doc.add(new LongPoint("f", value));
+      w.addDocument(doc);
+    }
+
+    DirectoryReader reader = DirectoryReader.open(w);
+    w.close();
+    IndexSearcher searcher = newSearcher(reader);
+    LongIntHashMap actualCounts =
+        searcher.search(new MatchAllDocsQuery(), new HistogramCollectorManager("f", 1000));
+    LongIntHashMap expectedCounts = new LongIntHashMap();
+    for (long value : values) {
+      expectedCounts.addTo(Math.floorDiv(value, 1000), 1);
+    }
+    assertEquals(expectedCounts, actualCounts);
+
+    reader.close();
+    dir.close();
+  }
+
   private void doTestSkipIndex(IndexWriterConfig cfg) throws IOException {
     Directory dir = newDirectory();
     IndexWriter w = new IndexWriter(dir, cfg);