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

tisonkun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datasketches-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 8bb74d7  perf(tdigest): separate point queries from batch scans (#312)
8bb74d7 is described below

commit 8bb74d7fe77d769ff9c20d2a3b8e8296928239ea
Author: tison <[email protected]>
AuthorDate: Tue Oct 27 21:05:43 2026 +0800

    perf(tdigest): separate point queries from batch scans (#312)
---
 CHANGELOG.md                                     |   1 +
 benchmarks/tdigest/query.rs                      |  41 ++++
 datasketches/src/tdigest/sketch.rs               | 253 ++++++++++++++---------
 tests-integration/tests/tdigest_test/property.rs |  38 ++++
 4 files changed, 239 insertions(+), 94 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53a9971..92c76dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All significant changes to this project will be documented in 
this file.
 ### Improvements
 
 * `KllSketch::rank` is faster for repeated queries on compacted sketches.
+* Repeated scalar `TDigest::rank` queries are faster, including small digests 
and data with repeated values.
 * `TDigest` and `TDigestMut` CDF and PMF batches are faster for many split 
points; batches with sparse split points keep the original binary-search 
behavior.
 * `BloomFilter::insert` is faster for streams of new items when multiple 
hashes are configured.
 
diff --git a/benchmarks/tdigest/query.rs b/benchmarks/tdigest/query.rs
index 76b05ac..d3cd330 100644
--- a/benchmarks/tdigest/query.rs
+++ b/benchmarks/tdigest/query.rs
@@ -24,6 +24,7 @@ use rand::seq::SliceRandom;
 
 use super::support::build_digest;
 use super::support::prepared_digest;
+use super::support::values;
 
 #[divan::bench]
 fn rank(bencher: Bencher) {
@@ -32,6 +33,34 @@ fn rank(bencher: Bencher) {
     bencher.bench_local(|| 
black_box(&digest).rank(black_box(0.531_25)).unwrap());
 }
 
+#[divan::bench]
+fn rank_mixed(bencher: Bencher) {
+    let digest = prepared_digest();
+    let queries = values(256);
+
+    bencher
+        .counter(ItemsCount::new(queries.len()))
+        .bench_local(|| {
+            for &value in black_box(&queries) {
+                black_box(black_box(&digest).rank(black_box(value)).unwrap());
+            }
+        });
+}
+
+#[divan::bench(args = [0.0, 2.5, 5.0])]
+fn rank_small(bencher: Bencher, value: f64) {
+    let digest = build_digest(&[0., 1., 2., 3., 4., 5.]);
+
+    bencher.bench_local(|| black_box(&digest).rank(black_box(value)).unwrap());
+}
+
+#[divan::bench]
+fn rank_mut(bencher: Bencher) {
+    let mut digest = prepared_digest().unfreeze();
+
+    bencher.bench_local(|| black_box(&mut 
digest).rank(black_box(0.531_25)).unwrap());
+}
+
 #[divan::bench(args = [0.0, 3.0, 3.5, 7.0])]
 fn rank_repeated_means(bencher: Bencher, value: f64) {
     let values = (0..100_000).map(|i| (i % 8) as f64).collect::<Vec<_>>();
@@ -57,6 +86,18 @@ fn cdf_100(bencher: Bencher) {
         .bench_local(|| 
black_box(&digest).cdf(black_box(&split_points)).unwrap());
 }
 
+#[divan::bench(args = [1, 6, 100, 512])]
+fn pmf(bencher: Bencher, num_split_points: usize) {
+    let digest = prepared_digest();
+    let split_points = (1..=num_split_points)
+        .map(|i| i as f64 / (num_split_points + 1) as f64)
+        .collect::<Vec<_>>();
+
+    bencher
+        .counter(ItemsCount::new(num_split_points))
+        .bench_local(|| 
black_box(&digest).pmf(black_box(&split_points)).unwrap());
+}
+
 #[divan::bench(consts = [true, false], args = [2, 6, 100])]
 fn quantiles_sequential<const SORTED: bool>(bencher: Bencher, num_ranks: 
usize) {
     let digest = prepared_digest();
diff --git a/datasketches/src/tdigest/sketch.rs 
b/datasketches/src/tdigest/sketch.rs
index 63661e7..83d8d2d 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -140,6 +140,7 @@ impl TDigestBuffer {
         self.centroids
     }
 
+    #[inline]
     fn compressed_centroids(&self) -> &[Centroid] {
         assert_eq!(
             self.unmerged_tail_len, 0,
@@ -392,6 +393,7 @@ impl TDigestMut {
         }
     }
 
+    #[inline]
     fn view(&mut self) -> TDigestView<'_> {
         self.compress(); // side effect
         TDigestView {
@@ -484,6 +486,7 @@ impl TDigestMut {
     /// let rank = sketch.rank(2.0).unwrap().unwrap();
     /// assert!((0.0..=1.0).contains(&rank));
     /// ```
+    #[inline]
     pub fn rank(&mut self, value: f64) -> Result<Option<f64>, Error> {
         if value.is_nan() {
             return Err(Error::invalid_argument("value must not be NaN"));
@@ -526,6 +529,7 @@ impl TDigestMut {
     /// let median = sketch.quantile(0.5).unwrap().unwrap();
     /// assert!((1.0..=3.0).contains(&median));
     /// ```
+    #[inline]
     pub fn quantile(&mut self, rank: f64) -> Result<Option<f64>, Error> {
         check_rank(rank)?;
 
@@ -1539,6 +1543,7 @@ impl TDigest {
     /// let rank = digest.rank(2.0).unwrap().unwrap();
     /// assert!((0.0..=1.0).contains(&rank));
     /// ```
+    #[inline]
     pub fn rank(&self, value: f64) -> Result<Option<f64>, Error> {
         if value.is_nan() {
             return Err(Error::invalid_argument("value must not be NaN"));
@@ -1570,6 +1575,7 @@ impl TDigest {
     /// let q = digest.quantile(0.5).unwrap().unwrap();
     /// assert!((1.0..=3.0).contains(&q));
     /// ```
+    #[inline]
     pub fn quantile(&self, rank: f64) -> Result<Option<f64>, Error> {
         check_rank(rank)?;
         Ok(self.view().quantile(rank))
@@ -1647,8 +1653,11 @@ struct TDigestView<'a> {
 impl TDigestView<'_> {
     fn pmf(&self, split_points: &[f64]) -> Option<Vec<f64>> {
         let mut buckets = self.cdf(split_points)?;
-        for i in (1..buckets.len()).rev() {
-            buckets[i] -= buckets[i - 1];
+        let mut previous = buckets[0];
+        for bucket in &mut buckets[1..] {
+            let rank = *bucket;
+            *bucket -= previous;
+            previous = rank;
         }
         Some(buckets)
     }
@@ -1657,126 +1666,185 @@ impl TDigestView<'_> {
         if self.centroids.is_empty() {
             return None;
         }
+        if split_points.is_empty() {
+            return Some(vec![1.0]);
+        }
 
-        // Split points increase, so their lower centroid indexes never move 
backwards.
-        // Accumulate integer weights only as far as each query needs.
         let binary_search_cost = self.centroids.len().ilog2() as usize;
         let use_scan = split_points.len().saturating_mul(binary_search_cost) > 
self.centroids.len();
-        let mut index = 0;
-        let mut previous_lower = 0;
-        let mut weight_below = 0;
-
         let mut ranks = Vec::with_capacity(split_points.len() + 1);
-        for &p in split_points {
-            ranks.push(self.rank_with_weights(
-                p,
-                |centroids, value| {
-                    if use_scan {
-                        while index < centroids.len() && centroids[index].mean 
< value {
-                            index += 1;
-                        }
-                        index
-                    } else {
-                        centroid_lower_bound_index(centroids, value)
-                    }
-                },
-                |lower| {
-                    weight_below += self.centroids[previous_lower..lower]
-                        .iter()
-                        .map(|c| c.weight.get())
-                        .sum::<u64>();
-                    previous_lower = lower;
-                    weight_below
-                },
-            ));
+        if use_scan || self.min == self.max {
+            // Constant sketches and scans can fill the sorted outer tails 
directly.
+            let start = split_points.partition_point(|&p| p < self.min);
+            let end = start + split_points[start..].partition_point(|&p| p <= 
self.max);
+            ranks.resize(start, 0.0);
+            if self.min == self.max {
+                ranks.resize(end, 0.5);
+            } else {
+                self.fill_cdf_scan(&split_points[start..end], &mut ranks);
+            }
+            ranks.resize(split_points.len(), 1.0);
+        } else {
+            self.fill_cdf_binary_search(split_points, &mut ranks);
         }
         ranks.push(1.0);
         Some(ranks)
     }
 
+    #[inline(always)]
     fn rank(&self, value: f64) -> Option<f64> {
         debug_assert!(!value.is_nan(), "value must not be NaN");
 
         if self.centroids.is_empty() {
             return None;
         }
-        Some(
-            self.rank_with_weights(value, centroid_lower_bound_index, |lower| {
-                self.centroids[..lower].iter().map(|c| c.weight.get()).sum()
-            }),
-        )
-    }
-
-    /// Rank within a non-empty centroid sequence, using the first centroid at 
or above `value`.
-    fn rank_with_weights(
-        &self,
-        value: f64,
-        find_index: impl FnOnce(&[Centroid], f64) -> usize,
-        weight_before: impl FnOnce(usize) -> u64,
-    ) -> f64 {
         if value < self.min {
-            return 0.0;
+            return Some(0.0);
         }
         if value > self.max {
-            return 1.0;
+            return Some(1.0);
         }
         if self.min == self.max {
-            return 0.5;
+            return Some(0.5);
         }
+        let total_weight = self.centroids_weight as f64;
+        let index = if value <= self.centroids[0].mean {
+            if value < self.centroids[0].mean {
+                return Some(self.mass_in_left_tail(value) / total_weight);
+            }
+            0
+        } else {
+            if value > self.centroids.last().unwrap().mean {
+                return Some(self.mass_in_right_tail(value) / total_weight);
+            }
+            self.centroids.partition_point(|c| c.mean < value)
+        };
+        let (lower, anchors) = self.rank_anchors(value, index);
+        let weight_below = self.centroids[..lower].iter().map(|c| 
c.weight.get()).sum();
+        Some(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight)
+    }
 
-        let centroids_weight = self.centroids_weight as f64;
-        let num_centroids = self.centroids.len();
-
-        // Reserve at most one sample for each stored extremum, without 
passing the adjacent
-        // centroid center. Updates can place a unit-weight centroid inside 
the stored extrema,
-        // leaving no mass to interpolate in that tail.
-        let first = &self.centroids[0];
-        if value < first.mean {
-            let center = centroid_center(0, first.weight.get());
-            return if value == self.min {
-                0.5 / centroids_weight
+    #[inline(always)]
+    fn fill_cdf_binary_search(&self, split_points: &[f64], ranks: &mut 
Vec<f64>) {
+        let total_weight = self.centroids_weight as f64;
+        let mut previous_lower = 0;
+        let mut weight_below = 0;
+        for &value in split_points {
+            if value < self.min {
+                ranks.push(0.0);
+                continue;
+            }
+            if value > self.max {
+                ranks.push(1.0);
+                continue;
+            }
+            let index = if value <= self.centroids[0].mean {
+                if value < self.centroids[0].mean {
+                    ranks.push(self.mass_in_left_tail(value) / total_weight);
+                    continue;
+                }
+                0
             } else {
-                interpolate(
-                    center.min(1.),
-                    center,
-                    interpolation_fraction(value, self.min, first.mean),
-                ) / centroids_weight
+                if value > self.centroids.last().unwrap().mean {
+                    ranks.push(self.mass_in_right_tail(value) / total_weight);
+                    continue;
+                }
+                self.centroids.partition_point(|c| c.mean < value)
             };
+            let (lower, anchors) = self.rank_anchors(value, index);
+            weight_below += self.centroids[previous_lower..lower]
+                .iter()
+                .map(|c| c.weight.get())
+                .sum::<u64>();
+            previous_lower = lower;
+            ranks.push(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight);
         }
+    }
 
-        let last = &self.centroids[num_centroids - 1];
-        if value > last.mean {
-            let center =
-                centroid_center(self.centroids_weight - last.weight.get(), 
last.weight.get());
-            return if value == self.max {
-                // Round the maximum's mass in the same coordinates as the 
centroid centers.
-                centroid_center(self.centroids_weight - 1, 1) / 
centroids_weight
+    #[inline(always)]
+    fn fill_cdf_scan(&self, split_points: &[f64], ranks: &mut Vec<f64>) {
+        let total_weight = self.centroids_weight as f64;
+        let mut index = 0;
+        let mut weight_before_index = 0;
+        for &value in split_points {
+            let lower_bound = if value <= self.centroids[0].mean {
+                if value < self.centroids[0].mean {
+                    ranks.push(self.mass_in_left_tail(value) / total_weight);
+                    continue;
+                }
+                0
             } else {
-                interpolate(
-                    center,
-                    center.max((self.centroids_weight - 1) as f64),
-                    interpolation_fraction(value, last.mean, self.max),
-                ) / centroids_weight
+                if value > self.centroids.last().unwrap().mean {
+                    ranks.push(self.mass_in_right_tail(value) / total_weight);
+                    continue;
+                }
+                while index < self.centroids.len() && 
self.centroids[index].mean < value {
+                    weight_before_index += self.centroids[index].weight.get();
+                    index += 1;
+                }
+                index
             };
+            let (lower, anchors) = self.rank_anchors(value, lower_bound);
+            // Between means, the lower anchor precedes index. Its weight 
belongs to the
+            // interpolation interval rather than the prefix below that 
interval.
+            let weight_below = weight_before_index
+                - if lower < lower_bound {
+                    self.centroids[lower].weight.get()
+                } else {
+                    0
+                };
+            ranks.push(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight);
         }
+    }
 
-        // The tail checks place value within the centroid means. Between 
means, the first
-        // mean >= value and its predecessor bracket it; at a mean, use the 
entire equal run.
-        let index = find_index(self.centroids, value);
-        let (lower, upper) = if self.centroids[index].mean == value {
+    #[inline]
+    fn mass_in_left_tail(&self, value: f64) -> f64 {
+        let center = centroid_center(0, self.centroids[0].weight.get());
+        if value == self.min {
+            0.5
+        } else {
+            interpolate(
+                center.min(1.),
+                center,
+                interpolation_fraction(value, self.min, 
self.centroids[0].mean),
+            )
+        }
+    }
+
+    #[inline]
+    fn mass_in_right_tail(&self, value: f64) -> f64 {
+        let last = self.centroids.last().unwrap();
+        let center = centroid_center(self.centroids_weight - 
last.weight.get(), last.weight.get());
+        if value == self.max {
+            // Use the same rounded mass as the adjacent centroid center.
+            centroid_center(self.centroids_weight - 1, 1)
+        } else {
+            interpolate(
+                center,
+                center.max((self.centroids_weight - 1) as f64),
+                interpolation_fraction(value, last.mean, self.max),
+            )
+        }
+    }
+
+    #[inline(always)]
+    fn rank_anchors(&self, value: f64, index: usize) -> (usize, &[Centroid]) {
+        // An exact mean spans its entire equal run; otherwise adjacent 
centroids bracket it.
+        if self.centroids[index].mean == value {
             let end = self.centroids.partition_point(|c| c.mean <= value);
-            (index, end - 1)
+            (index, &self.centroids[index..end])
         } else {
-            (index - 1, index)
-        };
+            (index - 1, &self.centroids[index - 1..=index])
+        }
+    }
 
-        let weight_below = weight_before(lower);
-        let weight_between: u64 = self.centroids[lower..upper]
-            .iter()
-            .map(|c| c.weight.get())
-            .sum();
-        let left = &self.centroids[lower];
-        let right = &self.centroids[upper];
+    #[inline(always)]
+    fn interpolated_mass(value: f64, anchors: &[Centroid], weight_below: u64) 
-> f64 {
+        let left = &anchors[0];
+        let Some((right, middle)) = anchors[1..].split_last() else {
+            return centroid_center(weight_below, left.weight.get());
+        };
+        let weight_between = left.weight.get() + middle.iter().map(|c| 
c.weight.get()).sum::<u64>();
         let fraction = if left.mean < right.mean {
             interpolation_fraction(value, left.mean, right.mean)
         } else {
@@ -1786,9 +1854,10 @@ impl TDigestView<'_> {
             centroid_center(weight_below, left.weight.get()),
             centroid_center(weight_below + weight_between, right.weight.get()),
             fraction,
-        ) / centroids_weight
+        )
     }
 
+    #[inline]
     fn quantile(&self, rank: f64) -> Option<f64> {
         debug_assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 
1.0]");
 
@@ -1986,10 +2055,6 @@ fn centroid_cmp(a: &Centroid, b: &Centroid) -> Ordering {
     }
 }
 
-fn centroid_lower_bound_index(centroids: &[Centroid], value: f64) -> usize {
-    centroids.partition_point(|centroid| centroid.mean < value)
-}
-
 fn centroids_are_sorted(centroids: &[Centroid]) -> bool {
     centroids
         .windows(2)
diff --git a/tests-integration/tests/tdigest_test/property.rs 
b/tests-integration/tests/tdigest_test/property.rs
index 431da0e..eff3a56 100644
--- a/tests-integration/tests/tdigest_test/property.rs
+++ b/tests-integration/tests/tdigest_test/property.rs
@@ -173,3 +173,41 @@ fn 
prop_batch_quantiles_preserve_input_order_and_duplicates() {
         .rng(Gen::new(1200))
         .quickcheck(property as fn(Vec<u32>, Vec<u8>));
 }
+
+#[test]
+fn prop_cdf_matches_scalar_ranks() {
+    fn property(values: Vec<u32>, points: Vec<u32>) {
+        let digest = digest_of(&values);
+        let frozen = digest.clone().freeze();
+        let mut points = points
+            .into_iter()
+            .map(|point| f64::from(point) / 4096.0)
+            .collect::<Vec<_>>();
+        points.extend_from_slice(&[f64::NEG_INFINITY, f64::INFINITY]);
+        points.sort_unstable_by(f64::total_cmp);
+        points.dedup();
+
+        let middle = vec![points[points.len() / 2]];
+        let sparse = points
+            .iter()
+            .step_by((points.len() / 6).max(1))
+            .copied()
+            .collect::<Vec<_>>();
+        for queries in [&[][..], &middle, &sparse, &points] {
+            let expected = (!frozen.is_empty()).then(|| {
+                queries
+                    .iter()
+                    .map(|&point| frozen.rank(point).unwrap().unwrap())
+                    .chain(std::iter::once(1.0))
+                    .collect::<Vec<_>>()
+            });
+            assert_eq!(frozen.cdf(queries).unwrap(), expected);
+            assert_eq!(digest.clone().cdf(queries).unwrap(), expected);
+        }
+    }
+
+    QuickCheck::new()
+        .tests(128)
+        .rng(Gen::new(512))
+        .quickcheck(property as fn(Vec<u32>, Vec<u32>));
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to