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 974a6e9  perf(tdigest): single centroid buffer (#210)
974a6e9 is described below

commit 974a6e9b03d2f417398f5a135cda86a5bb90fe42
Author: Yijun Zhao <[email protected]>
AuthorDate: Tue Aug 25 09:13:25 2026 +0800

    perf(tdigest): single centroid buffer (#210)
    
    Co-authored-by: tison <[email protected]>
---
 datasketches/benches/tdigest.rs           |  42 ++-
 datasketches/src/tdigest/sketch.rs        | 526 ++++++++++++++++++++++--------
 datasketches/tests/serde_tests/tdigest.rs | 124 +++++++
 datasketches/tests/tdigest_test/sketch.rs |  57 ++++
 4 files changed, 607 insertions(+), 142 deletions(-)

diff --git a/datasketches/benches/tdigest.rs b/datasketches/benches/tdigest.rs
index b68bd59..b291770 100644
--- a/datasketches/benches/tdigest.rs
+++ b/datasketches/benches/tdigest.rs
@@ -88,11 +88,37 @@ fn partial_digest_lifecycle_by_k(bencher: Bencher, k: u16) {
 }
 
 #[divan::bench]
-fn compress(bencher: Bencher) {
+fn compress_initial_buffer(bencher: Bencher) {
     // The default k=200 digest buffers 1,640 values before automatic 
compression.
     let values = values(1_640);
     let digest = build_mut_digest(&values);
 
+    bencher
+        .counter(ItemsCount::new(values.len()))
+        .with_inputs(|| digest.clone())
+        .bench_local_values(|mut digest| black_box(digest.rank(0.5)));
+}
+
+#[divan::bench]
+fn compress_unmerged_tail(bencher: Bencher) {
+    let values = values(3_280);
+    let mut digest = build_mut_digest(&values[..1_640]);
+    black_box(digest.rank(0.5));
+    for &value in &values[1_640..] {
+        digest.update(value);
+    }
+
+    bencher
+        .counter(ItemsCount::new(1_640_usize))
+        .with_inputs(|| digest.clone())
+        .bench_local_values(|mut digest| black_box(digest.rank(0.5)));
+}
+
+#[divan::bench]
+fn freeze_initial_buffer(bencher: Bencher) {
+    let values = values(1_640);
+    let digest = build_mut_digest(&values);
+
     bencher
         .counter(ItemsCount::new(values.len()))
         .with_inputs(|| digest.clone())
@@ -116,6 +142,20 @@ fn merge(bencher: Bencher) {
         });
 }
 
+#[divan::bench(args = [8, 1_640])]
+fn unmerged_merge(bencher: Bencher, left_rows: usize) {
+    let left = build_mut_digest(&values(left_rows));
+    let right = build_mut_digest(&values(1_640));
+
+    bencher
+        .counter(ItemsCount::new(left_rows + 1_640))
+        .with_inputs(|| left.clone())
+        .bench_local_values(|mut left| {
+            left.merge(black_box(&right));
+            black_box(left)
+        });
+}
+
 #[divan::bench]
 fn rank(bencher: Bencher) {
     let digest = prepared_digest();
diff --git a/datasketches/src/tdigest/sketch.rs 
b/datasketches/src/tdigest/sketch.rs
index 7e216f3..e24a99f 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -37,13 +37,93 @@ use crate::tdigest::serialization::SERIAL_VERSION;
 
 /// The default value of K if one is not specified.
 const DEFAULT_K: u16 = 200;
-/// Multiplier for buffer size relative to centroids capacity.
-const BUFFER_MULTIPLIER: usize = 4;
-/// Buffer capacity allocated by the first update to a digest.
-const INITIAL_BUFFER_CAPACITY: usize = 8;
+/// Multiplier for unmerged values relative to the target number of centroids.
+const UNMERGED_MULTIPLIER: usize = 4;
+/// Unmerged-value capacity allocated by the first update to a digest.
+const INITIAL_UNMERGED_CAPACITY: usize = 8;
 /// Default weight for single values.
 const DEFAULT_WEIGHT: NonZeroU64 = NonZeroU64::new(1).unwrap();
 
+// The update buffer has two physical representations:
+//
+// * `Staging` stores raw `f64` values compactly before the first compression.
+// * `Centroids` stores `[compressed prefix | unmerged unit-weight tail]` in 
one allocation. The
+//   tail length identifies the boundary between the two regions.
+//
+// Compression permanently transitions a non-empty buffer from `Staging` to 
`Centroids`.
+#[derive(Debug, Clone)]
+enum TDigestBuffer {
+    Staging(Vec<f64>),
+    Centroids {
+        centroids: Vec<Centroid>,
+        unmerged_tail_len: usize,
+    },
+}
+
+impl Default for TDigestBuffer {
+    fn default() -> Self {
+        TDigestBuffer::Staging(vec![])
+    }
+}
+
+impl TDigestBuffer {
+    fn len(&self) -> usize {
+        match self {
+            TDigestBuffer::Staging(values) => values.len(),
+            TDigestBuffer::Centroids { centroids, .. } => centroids.len(),
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    fn unmerged_len(&self) -> usize {
+        match self {
+            TDigestBuffer::Staging(values) => values.len(),
+            TDigestBuffer::Centroids {
+                unmerged_tail_len, ..
+            } => *unmerged_tail_len,
+        }
+    }
+
+    /// Returns compressed centroids after the caller has processed staged 
values.
+    fn compressed_centroids(&self) -> &[Centroid] {
+        match self {
+            TDigestBuffer::Staging(values) if values.is_empty() => &[],
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len: 0,
+            } => centroids,
+            _ => unreachable!(
+                "t-digest buffer must be compressed before reading centroids: 
{self:?}"
+            ),
+        }
+    }
+
+    fn into_compressed_centroids(self) -> Vec<Centroid> {
+        match self {
+            TDigestBuffer::Staging(values) if values.is_empty() => vec![],
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len: 0,
+            } => centroids,
+            _ => unreachable!(
+                "t-digest buffer must be compressed before reading centroids: 
{self:?}"
+            ),
+        }
+    }
+
+    fn estimated_size(&self) -> usize {
+        match self {
+            TDigestBuffer::Staging(values) => values.capacity() * 
size_of::<f64>(),
+            TDigestBuffer::Centroids { centroids, .. } => {
+                centroids.capacity() * size_of::<Centroid>()
+            }
+        }
+    }
+}
+
 /// T-Digest sketch for estimating quantiles and ranks.
 ///
 /// See the [module level documentation](super) for more.
@@ -55,10 +135,10 @@ pub struct TDigestMut {
     min: f64,
     max: f64,
 
-    centroids: Vec<Centroid>,
-    centroids_weight: u64,
-    centroids_capacity: usize,
-    buffer: Vec<f64>,
+    buffer: TDigestBuffer,
+    // Weight represented by the compressed prefix. Staged values or the 
unmerged tail contribute
+    // one each and are counted separately by `TDigestBuffer::unmerged_len`.
+    compressed_weight: u64,
 }
 
 impl Default for TDigestMut {
@@ -90,9 +170,8 @@ impl TDigestMut {
             false,
             f64::INFINITY,
             f64::NEG_INFINITY,
-            vec![],
+            TDigestBuffer::Staging(vec![]),
             0,
-            vec![],
         )
     }
 
@@ -124,9 +203,8 @@ impl TDigestMut {
             false,
             f64::INFINITY,
             f64::NEG_INFINITY,
-            vec![],
+            TDigestBuffer::Staging(vec![]),
             0,
-            vec![],
         ))
     }
 
@@ -136,27 +214,41 @@ impl TDigestMut {
         reverse_merge: bool,
         min: f64,
         max: f64,
-        centroids: Vec<Centroid>,
-        centroids_weight: u64,
-        buffer: Vec<f64>,
+        buffer: TDigestBuffer,
+        compressed_weight: u64,
     ) -> Self {
         assert!(k >= 10, "k must be at least 10");
-
-        let fudge = if k < 30 { 30 } else { 10 };
-        let centroids_capacity = (k as usize * 2) + fudge;
+        debug_assert!(match &buffer {
+            TDigestBuffer::Staging(_) => compressed_weight == 0,
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len,
+            } => *unmerged_tail_len <= centroids.len(),
+        });
 
         TDigestMut {
             k,
             reverse_merge,
             min,
             max,
-            centroids,
-            centroids_weight,
-            centroids_capacity,
             buffer,
+            compressed_weight,
         }
     }
 
+    fn target_centroids(&self) -> usize {
+        let fudge = if self.k < 30 { 30 } else { 10 };
+        (usize::from(self.k) * 2) + fudge
+    }
+
+    fn max_unmerged(&self) -> usize {
+        self.target_centroids() * UNMERGED_MULTIPLIER
+    }
+
+    fn target_retained_capacity(&self) -> usize {
+        self.target_centroids() + self.max_unmerged()
+    }
+
     /// Updates this t-digest with the given value.
     ///
     /// [f64::NAN], [f64::INFINITY], and [f64::NEG_INFINITY] values are 
ignored.
@@ -175,29 +267,71 @@ impl TDigestMut {
             return;
         }
 
-        let full_buffer_capacity = self.centroids_capacity * BUFFER_MULTIPLIER;
-        if self.buffer.len() == full_buffer_capacity {
+        let max_unmerged = self.max_unmerged();
+        if let TDigestBuffer::Staging(values) = &mut self.buffer {
+            if values.len() < max_unmerged {
+                if values.len() == values.capacity() {
+                    let target_capacity = if values.capacity() == 0 {
+                        INITIAL_UNMERGED_CAPACITY
+                    } else if values.capacity() == INITIAL_UNMERGED_CAPACITY {
+                        // Once a digest outgrows a tiny group, skip an extra 
allocator round trip
+                        // while keeping the first allocation small.
+                        (INITIAL_UNMERGED_CAPACITY * UNMERGED_MULTIPLIER * 
UNMERGED_MULTIPLIER)
+                            .min(max_unmerged)
+                    } else {
+                        values
+                            .capacity()
+                            .saturating_mul(UNMERGED_MULTIPLIER)
+                            .min(max_unmerged)
+                    };
+                    
values.reserve_exact(target_capacity.saturating_sub(values.len()));
+                }
+
+                values.push(value);
+                self.min = self.min.min(value);
+                self.max = self.max.max(value);
+                return;
+            }
             self.compress();
         }
-        if self.buffer.len() == self.buffer.capacity() {
-            let target_capacity = if self.buffer.capacity() == 0 {
-                INITIAL_BUFFER_CAPACITY
-            } else if self.buffer.capacity() == INITIAL_BUFFER_CAPACITY {
+
+        if matches!(
+            &self.buffer,
+            TDigestBuffer::Centroids { unmerged_tail_len, .. } if 
*unmerged_tail_len >= max_unmerged
+        ) {
+            self.compress();
+        }
+
+        let TDigestBuffer::Centroids {
+            centroids,
+            unmerged_tail_len,
+        } = &mut self.buffer
+        else {
+            unreachable!("a full staging buffer must become centroid-backed 
after compression");
+        };
+        if centroids.len() == centroids.capacity() {
+            let target_unmerged = if *unmerged_tail_len == 0 {
+                INITIAL_UNMERGED_CAPACITY
+            } else if *unmerged_tail_len == INITIAL_UNMERGED_CAPACITY {
                 // Once a digest outgrows a tiny group, skip an extra 
allocator round trip while
                 // keeping the first allocation small.
-                (INITIAL_BUFFER_CAPACITY * BUFFER_MULTIPLIER * 
BUFFER_MULTIPLIER)
-                    .min(full_buffer_capacity)
+                (INITIAL_UNMERGED_CAPACITY * UNMERGED_MULTIPLIER * 
UNMERGED_MULTIPLIER)
+                    .min(max_unmerged)
             } else {
-                self.buffer
-                    .capacity()
-                    .saturating_mul(BUFFER_MULTIPLIER)
-                    .min(full_buffer_capacity)
+                unmerged_tail_len
+                    .saturating_mul(UNMERGED_MULTIPLIER)
+                    .min(max_unmerged)
             };
-            self.buffer
-                
.reserve_exact(target_capacity.saturating_sub(self.buffer.len()));
+            let num_merged = centroids.len() - *unmerged_tail_len;
+            let target_capacity = num_merged.saturating_add(target_unmerged);
+            
centroids.reserve_exact(target_capacity.saturating_sub(centroids.len()));
         }
 
-        self.buffer.push(value);
+        centroids.push(Centroid {
+            mean: value,
+            weight: DEFAULT_WEIGHT,
+        });
+        *unmerged_tail_len += 1;
         self.min = self.min.min(value);
         self.max = self.max.max(value);
     }
@@ -209,7 +343,7 @@ impl TDigestMut {
 
     /// Returns `true` if this t-digest has not seen any data.
     pub fn is_empty(&self) -> bool {
-        self.centroids.is_empty() && self.buffer.is_empty()
+        self.buffer.is_empty()
     }
 
     /// Returns the minimum value seen by this t-digest, or `None` if it is 
empty.
@@ -232,7 +366,7 @@ impl TDigestMut {
 
     /// Returns the total weight.
     pub fn total_weight(&self) -> u64 {
-        self.centroids_weight + self.buffer.len() as u64
+        self.compressed_weight + self.buffer.unmerged_len() as u64
     }
 
     /// Merges the given t-digest into this one.
@@ -254,29 +388,47 @@ impl TDigestMut {
             return;
         }
 
-        let additional_centroids = self.buffer.len() + other.centroids.len() + 
other.buffer.len();
-        let mut tmp = std::mem::take(&mut self.centroids);
-        let num_existing_centroids = tmp.len();
-        tmp.reserve(additional_centroids);
-        for &v in &self.buffer {
-            tmp.push(Centroid {
-                mean: v,
-                weight: DEFAULT_WEIGHT,
-            });
-        }
-        for &v in &other.buffer {
-            tmp.push(Centroid {
-                mean: v,
-                weight: DEFAULT_WEIGHT,
-            });
-        }
-        for &c in &other.centroids {
-            tmp.push(c);
+        let buffer = std::mem::take(&mut self.buffer);
+        let (mut merge_buffer, existing_prefix_len, self_unmerged_weight) = 
match buffer {
+            TDigestBuffer::Staging(values) => {
+                let self_unmerged_weight = values.len() as u64;
+                let mut merge_buffer = Vec::with_capacity(values.len() + 
other.buffer.len());
+                merge_buffer.extend(values.into_iter().map(|mean| Centroid {
+                    mean,
+                    weight: DEFAULT_WEIGHT,
+                }));
+                (merge_buffer, 0, self_unmerged_weight)
+            }
+            TDigestBuffer::Centroids {
+                mut centroids,
+                unmerged_tail_len,
+            } => {
+                let existing_prefix_len = centroids.len() - unmerged_tail_len;
+                centroids.reserve(other.buffer.len());
+                (centroids, existing_prefix_len, unmerged_tail_len as u64)
+            }
+        };
+        match &other.buffer {
+            TDigestBuffer::Staging(values) => {
+                merge_buffer.extend(values.iter().copied().map(|mean| Centroid 
{
+                    mean,
+                    weight: DEFAULT_WEIGHT,
+                }));
+            }
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len,
+            } => {
+                let other_prefix_len = centroids.len() - unmerged_tail_len;
+                merge_buffer.extend_from_slice(&centroids[other_prefix_len..]);
+                merge_buffer.extend_from_slice(&centroids[..other_prefix_len]);
+            }
         }
         // Preserve the original insertion order for equal means because 
t-digest requires a stable
-        // sort: buffered values, the other digest's centroids, then this 
digest's centroids.
-        tmp.rotate_left(num_existing_centroids);
-        self.do_merge(tmp, self.buffer.len() as u64 + other.total_weight())
+        // sort: this digest's buffered/unmerged values, the other digest's 
buffered/unmerged values
+        // and compressed centroids, then this digest's compressed centroids.
+        merge_buffer.rotate_left(existing_prefix_len);
+        self.compress_centroids(merge_buffer, self_unmerged_weight + 
other.total_weight())
     }
 
     /// Converts this mutable t-digest into an immutable one.
@@ -293,13 +445,17 @@ impl TDigestMut {
     /// ```
     pub fn freeze(mut self) -> TDigest {
         self.compress();
+        let mut centroids = self.buffer.into_compressed_centroids();
+        // A mutable digest retains update workspace for reuse. The immutable 
form cannot use that
+        // spare capacity, so release it at this consuming boundary.
+        centroids.shrink_to_fit();
         TDigest {
             k: self.k,
             reverse_merge: self.reverse_merge,
             min: self.min,
             max: self.max,
-            centroids: self.centroids,
-            centroids_weight: self.centroids_weight,
+            centroids,
+            centroids_weight: self.compressed_weight,
         }
     }
 
@@ -308,8 +464,8 @@ impl TDigestMut {
         TDigestView {
             min: self.min,
             max: self.max,
-            centroids: &self.centroids,
-            centroids_weight: self.centroids_weight,
+            centroids: self.buffer.compressed_centroids(),
+            centroids_weight: self.compressed_weight,
         }
     }
 
@@ -388,7 +544,7 @@ impl TDigestMut {
             return Some(1.0);
         }
         // one centroid and value == min == max
-        if self.centroids.len() + self.buffer.len() == 1 {
+        if self.buffer.len() == 1 {
             return Some(0.5);
         }
 
@@ -434,6 +590,7 @@ impl TDigestMut {
     /// ```
     pub fn serialize(&mut self) -> Vec<u8> {
         self.compress();
+        let centroids = self.buffer.compressed_centroids();
 
         let mut total_size = 0;
         if self.is_empty() || self.is_single_value() {
@@ -460,7 +617,7 @@ impl TDigestMut {
             // + 8 bytes max
             total_size += size_of::<f64>() * 2;
             // + (8+8) bytes per centroid
-            total_size += self.centroids.len() * (size_of::<f64>() + 
size_of::<u64>());
+            total_size += centroids.len() * (size_of::<f64>() + 
size_of::<u64>());
         }
 
         let mut bytes = SketchBytes::with_capacity(total_size);
@@ -493,11 +650,11 @@ impl TDigestMut {
             bytes.write_f64_le(self.min);
             return bytes.into_bytes();
         }
-        bytes.write_u32_le(self.centroids.len() as u32);
+        bytes.write_u32_le(centroids.len() as u32);
         bytes.write_u32_le(0); // unused
         bytes.write_f64_le(self.min);
         bytes.write_f64_le(self.max);
-        for centroid in &self.centroids {
+        for centroid in centroids {
             bytes.write_f64_le(centroid.mean);
             bytes.write_u64_le(centroid.weight.get());
         }
@@ -582,12 +739,14 @@ impl TDigestMut {
                 reverse_merge,
                 value,
                 value,
-                vec![Centroid {
-                    mean: value,
-                    weight: DEFAULT_WEIGHT,
-                }],
+                TDigestBuffer::Centroids {
+                    centroids: vec![Centroid {
+                        mean: value,
+                        weight: DEFAULT_WEIGHT,
+                    }],
+                    unmerged_tail_len: 0,
+                },
                 1,
-                vec![],
             ));
         }
         let num_centroids = cursor
@@ -611,8 +770,56 @@ impl TDigestMut {
         check_non_nan(max, "max")?;
         check_finite(min, "min")?;
         check_finite(max, "max")?;
-        let mut centroids = Vec::with_capacity(num_centroids);
-        let mut centroids_weight = 0u64;
+        let (centroid_bytes, buffered_value_bytes) = if is_f32 {
+            (size_of::<f32>() + size_of::<u32>(), size_of::<f32>())
+        } else {
+            (size_of::<f64>() + size_of::<u64>(), size_of::<f64>())
+        };
+        let required_payload_bytes = num_centroids
+            .checked_mul(centroid_bytes)
+            .and_then(|bytes| {
+                num_buffered
+                    .checked_mul(buffered_value_bytes)
+                    .and_then(|buffered_bytes| 
bytes.checked_add(buffered_bytes))
+            })
+            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
+        if cursor.remaining().len() < required_payload_bytes {
+            return Err(Error::insufficient_data(format!(
+                "TDigest payload requires {required_payload_bytes} bytes, got 
{}",
+                cursor.remaining().len()
+            )));
+        }
+        if num_centroids == 0 {
+            checked_weight_sum(0, num_buffered as u64)?;
+            let mut initial_buffer = Vec::with_capacity(num_buffered);
+            for _ in 0..num_buffered {
+                let value = if is_f32 {
+                    cursor
+                        .read_f32_le()
+                        .map_err(insufficient_data("buffered_value"))? as f64
+                } else {
+                    cursor
+                        .read_f64_le()
+                        .map_err(insufficient_data("buffered_value"))?
+                };
+                check_non_nan(value, "buffered_value mean")?;
+                check_finite(value, "buffered_value mean")?;
+                initial_buffer.push(value);
+            }
+            return Ok(TDigestMut::make(
+                k,
+                reverse_merge,
+                min,
+                max,
+                TDigestBuffer::Staging(initial_buffer),
+                0,
+            ));
+        }
+        let stored_centroids = 
num_centroids.checked_add(num_buffered).ok_or_else(|| {
+            Error::deserial("num_centroids and num_buffered exceed the 
supported size")
+        })?;
+        let mut centroids = Vec::with_capacity(stored_centroids);
+        let mut compressed_weight = 0u64;
         for _ in 0..num_centroids {
             let (mean, weight) = if is_f32 {
                 (
@@ -628,11 +835,10 @@ impl TDigestMut {
             check_non_nan(mean, "centroid mean")?;
             check_finite(mean, "centroid")?;
             let weight = check_nonzero(weight, "centroid weight")?;
-            centroids_weight = checked_weight_sum(centroids_weight, 
weight.get())?;
+            compressed_weight = checked_weight_sum(compressed_weight, 
weight.get())?;
             centroids.push(Centroid { mean, weight });
         }
-        checked_weight_sum(centroids_weight, num_buffered as u64)?;
-        let mut buffer = Vec::with_capacity(num_buffered);
+        checked_weight_sum(compressed_weight, num_buffered as u64)?;
         for _ in 0..num_buffered {
             let value = if is_f32 {
                 cursor
@@ -645,16 +851,21 @@ impl TDigestMut {
             };
             check_non_nan(value, "buffered_value mean")?;
             check_finite(value, "buffered_value mean")?;
-            buffer.push(value);
+            centroids.push(Centroid {
+                mean: value,
+                weight: DEFAULT_WEIGHT,
+            });
         }
         Ok(TDigestMut::make(
             k,
             reverse_merge,
             min,
             max,
-            centroids,
-            centroids_weight,
-            buffer,
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len: num_buffered,
+            },
+            compressed_weight,
         ))
     }
 
@@ -705,9 +916,11 @@ impl TDigestMut {
                     false,
                     min,
                     max,
-                    centroids,
+                    TDigestBuffer::Centroids {
+                        centroids,
+                        unmerged_tail_len: 0,
+                    },
                     total_weight,
-                    vec![],
                 ))
             }
             COMPAT_FLOAT => {
@@ -750,9 +963,11 @@ impl TDigestMut {
                     false,
                     min,
                     max,
-                    centroids,
+                    TDigestBuffer::Centroids {
+                        centroids,
+                        unmerged_tail_len: 0,
+                    },
                     total_weight,
-                    vec![],
                 ))
             }
             ty => Err(Error::deserial(format!("unknown TDigest compat type 
{ty}"))),
@@ -763,96 +978,123 @@ impl TDigestMut {
         self.total_weight() == 1
     }
 
-    /// Process buffered values and merge centroids if needed.
+    /// Processes unmerged values and merges centroids if needed.
     fn compress(&mut self) {
-        if self.buffer.is_empty() {
-            return;
-        }
-        let mut tmp = std::mem::take(&mut self.centroids);
-        let full_buffer_capacity = self.centroids_capacity * BUFFER_MULTIPLIER;
-        let required_capacity = tmp.len() + self.buffer.len();
-        let target_capacity = if self.buffer.len() == full_buffer_capacity {
-            required_capacity.max(full_buffer_capacity + 
self.centroids_capacity)
-        } else {
-            required_capacity
-        };
-        tmp.reserve_exact(target_capacity.saturating_sub(tmp.len()));
-        let num_buffered = self.buffer.len();
-        for &v in &self.buffer {
-            tmp.push(Centroid {
-                mean: v,
-                weight: DEFAULT_WEIGHT,
-            });
+        let buffer = std::mem::take(&mut self.buffer);
+        match buffer {
+            TDigestBuffer::Staging(values) if values.is_empty() => {
+                self.buffer = TDigestBuffer::Staging(values);
+            }
+            TDigestBuffer::Staging(values) => {
+                debug_assert_eq!(self.compressed_weight, 0);
+                let weight = values.len() as u64;
+                let mut centroids = Vec::with_capacity(values.len());
+                centroids.extend(values.into_iter().map(|mean| Centroid {
+                    mean,
+                    weight: DEFAULT_WEIGHT,
+                }));
+                self.compress_centroids(centroids, weight);
+            }
+            TDigestBuffer::Centroids {
+                centroids,
+                unmerged_tail_len: 0,
+            } => {
+                // Preserve compact deserialized images verbatim, including 
images with more
+                // centroids than this implementation would normally produce.
+                self.buffer = TDigestBuffer::Centroids {
+                    centroids,
+                    unmerged_tail_len: 0,
+                };
+            }
+            TDigestBuffer::Centroids {
+                mut centroids,
+                unmerged_tail_len,
+            } => {
+                let compressed_prefix_len = centroids.len() - 
unmerged_tail_len;
+                // Preserve the original insertion order for equal means 
because t-digest requires
+                // a stable sort: unmerged values before existing centroids.
+                centroids.rotate_left(compressed_prefix_len);
+                self.compress_centroids(centroids, unmerged_tail_len as u64);
+            }
         }
-        // Preserve the original insertion order for equal means because 
t-digest requires a stable
-        // sort: buffered values before existing centroids.
-        tmp.rotate_right(num_buffered);
-        self.do_merge(tmp, self.buffer.len() as u64)
     }
 
-    /// Merges the given buffer of centroids into this TDigest.
+    /// Compresses the given centroids into this t-digest.
     ///
     /// # Contract
     ///
-    /// * `buffer` must contain at least one centroid.
-    /// * `buffer` contains every centroid to be merged, including all 
centroids previously stored
-    ///   in `self`.
-    /// * `weight` is the total weight not yet included in 
`self.centroids_weight`.
-    /// * Every centroid mean in `buffer` is finite.
-    /// * `self.buffer` is cleared before returning.
-    fn do_merge(&mut self, mut buffer: Vec<Centroid>, weight: u64) {
-        buffer.sort_by(centroid_cmp);
+    /// * `centroids` must contain at least one centroid.
+    /// * `centroids` contains every centroid to be merged, including all 
centroids previously
+    ///   stored in `self`.
+    /// * `additional_weight` is the total weight not yet included in 
`self.compressed_weight`.
+    /// * Every centroid mean in `centroids` is finite.
+    /// * `self.buffer` becomes centroid-backed with no unmerged values before 
returning.
+    fn compress_centroids(&mut self, mut centroids: Vec<Centroid>, 
additional_weight: u64) {
+        debug_assert!(!centroids.is_empty());
+        centroids.sort_by(centroid_cmp);
         if self.reverse_merge {
-            buffer.reverse();
+            centroids.reverse();
         }
-        self.centroids_weight += weight;
+        self.compressed_weight += additional_weight;
 
         let mut num_centroids = 1;
-        let len = buffer.len();
-        let centroids_weight = self.centroids_weight as f64;
-        let normalizer = scale_function::normalizer(2.0 * f64::from(self.k), 
centroids_weight);
+        let len = centroids.len();
+        let compressed_weight = self.compressed_weight as f64;
+        let normalizer = scale_function::normalizer(2.0 * f64::from(self.k), 
compressed_weight);
         let mut current = 1;
         let mut weight_so_far = 0.;
         while current < len {
-            let c = buffer[current];
-            let proposed_weight = buffer[num_centroids - 1].weight() + 
c.weight();
+            let c = centroids[current];
+            let proposed_weight = centroids[num_centroids - 1].weight() + 
c.weight();
             let mut add_this = false;
             if (current != 1) && (current != (len - 1)) {
-                let q0 = weight_so_far / centroids_weight;
-                let q2 = (weight_so_far + proposed_weight) / centroids_weight;
+                let q0 = weight_so_far / compressed_weight;
+                let q2 = (weight_so_far + proposed_weight) / compressed_weight;
                 add_this = proposed_weight
-                    <= (centroids_weight
+                    <= (compressed_weight
                         * scale_function::max(q0, normalizer)
                             .min(scale_function::max(q2, normalizer)));
             }
             if add_this {
                 // merge into existing centroid
-                buffer[num_centroids - 1].add(c);
+                centroids[num_centroids - 1].add(c);
             } else {
                 // copy to a new centroid
-                weight_so_far += buffer[num_centroids - 1].weight();
-                buffer[num_centroids] = c;
+                weight_so_far += centroids[num_centroids - 1].weight();
+                centroids[num_centroids] = c;
                 num_centroids += 1;
             }
             current += 1;
         }
 
-        buffer.truncate(num_centroids);
+        centroids.truncate(num_centroids);
         if self.reverse_merge {
-            buffer.reverse();
+            centroids.reverse();
         }
-        self.min = self.min.min(buffer[0].mean);
-        self.max = self.max.max(buffer[num_centroids - 1].mean);
-        self.centroids = buffer;
+        self.min = self.min.min(centroids[0].mean);
+        self.max = self.max.max(centroids[num_centroids - 1].mean);
         self.reverse_merge = !self.reverse_merge;
-        self.buffer.clear();
+        self.reduce_retained_capacity(&mut centroids);
+        self.buffer = TDigestBuffer::Centroids {
+            centroids,
+            unmerged_tail_len: 0,
+        };
+    }
+
+    fn reduce_retained_capacity(&self, centroids: &mut Vec<Centroid>) {
+        let target_capacity = 
self.target_retained_capacity().max(centroids.len());
+        if centroids.capacity() <= target_capacity {
+            return;
+        }
+
+        // A merge can temporarily exceed the update-path target. Shrink after 
compaction so one
+        // unusually large input does not pin that peak capacity for the rest 
of the digest's life.
+        centroids.shrink_to(target_capacity);
     }
 
     /// Returns the estimated size of the sketch in bytes.
     pub fn estimated_size(&self) -> usize {
-        size_of::<Self>()
-            + self.centroids.capacity() * size_of::<Centroid>()
-            + self.buffer.capacity() * size_of::<f64>()
+        size_of::<Self>() + self.buffer.estimated_size()
     }
 }
 
@@ -1062,9 +1304,11 @@ impl TDigest {
             self.reverse_merge,
             self.min,
             self.max,
-            self.centroids,
+            TDigestBuffer::Centroids {
+                centroids: self.centroids,
+                unmerged_tail_len: 0,
+            },
             self.centroids_weight,
-            vec![],
         )
     }
 
diff --git a/datasketches/tests/serde_tests/tdigest.rs 
b/datasketches/tests/serde_tests/tdigest.rs
index 7c860d7..34463fc 100644
--- a/datasketches/tests/serde_tests/tdigest.rs
+++ b/datasketches/tests/serde_tests/tdigest.rs
@@ -25,6 +25,21 @@ use googletest::prelude::near;
 
 use crate::serialization_test_data;
 
+fn fnv1a(bytes: &[u8]) -> u64 {
+    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
+        (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
+    })
+}
+
+fn patterned_digest(k: u16, len: usize, salt: usize) -> TDigestMut {
+    let mut tdigest = TDigestMut::new(k);
+    for index in 0..len {
+        let value = (((index * 37 + salt * 17) % 101) as f64) - 50.0;
+        tdigest.update(value);
+    }
+    tdigest
+}
+
 fn test_sketch_file(path: PathBuf, n: u64, with_buffer: bool, is_f32: bool) {
     let bytes = fs::read(&path).unwrap();
     let td = TDigestMut::deserialize(&bytes, is_f32).unwrap();
@@ -202,6 +217,115 @@ fn test_many_values() {
     assert_eq!(td.quantile(0.5), deserialized_td.quantile(0.5));
 }
 
+#[test]
+fn test_serialized_bytes_stable_for_full_and_merged_digests() {
+    let mut full_buffer = patterned_digest(200, 1_641, 0);
+    let bytes = full_buffer.serialize();
+    assert_eq!(bytes.len(), 2_864);
+    assert_eq!(fnv1a(&bytes), 0x5c01_c50d_d1c8_fdbb);
+
+    let mut left = patterned_digest(10, 201, 2);
+    let mut right = patterned_digest(10, 199, 3);
+    right.rank(0.0);
+    left.merge(&right);
+    let bytes = left.serialize();
+    assert_eq!(bytes.len(), 272);
+    assert_eq!(fnv1a(&bytes), 0x7d2e_a927_9b9e_f559);
+
+    for &(left_len, right_len, expected_len, expected_hash) in &[
+        (8, 201, 272, 0x8522_1f3f_152f_24e5),
+        (201, 8, 256, 0xe60d_1f6f_f4b0_73e0),
+        (201, 401, 288, 0x4cb8_4037_5e68_ca4b),
+        (401, 201, 288, 0x6f6d_e965_77a7_a53f),
+    ] {
+        let mut left = patterned_digest(10, left_len, 2);
+        let right = patterned_digest(10, right_len, 3);
+        left.merge(&right);
+        let bytes = left.serialize();
+        assert_eq!(bytes.len(), expected_len);
+        assert_eq!(fnv1a(&bytes), expected_hash);
+    }
+}
+
+#[test]
+fn test_updates_normalize_overfull_deserialized_staging_buffer() {
+    let path = serialization_test_data("cpp_generated_files", 
"tdigest_double_buf_n10_cpp.sk");
+    let mut bytes = fs::read(path).unwrap();
+    assert_eq!(&bytes[8..12], &0_u32.to_le_bytes()); // num centroids
+    assert_eq!(&bytes[12..16], &10_u32.to_le_bytes()); // num buffered
+
+    // k=100 normally compresses at 840 buffered values. Extend a real C++ 
staging image just past
+    // that producer threshold while keeping every added value within the 
recorded min/max range.
+    bytes[12..16].copy_from_slice(&841_u32.to_le_bytes());
+    for _ in 0..831 {
+        bytes.extend_from_slice(&10_f64.to_le_bytes());
+    }
+
+    let mut tdigest = TDigestMut::deserialize(&bytes, false).unwrap();
+    for _ in 0..10_000 {
+        tdigest.update(10.0);
+    }
+
+    assert_eq!(tdigest.total_weight(), 10_841);
+    assert_eq!(tdigest.min_value(), Some(1.0));
+    assert_eq!(tdigest.max_value(), Some(10.0));
+    // The overfull image must not disable future compression and let the 
staging buffer grow with
+    // every subsequent value.
+    assert!(tdigest.estimated_size() < 32_768);
+    let serialized = tdigest.serialize();
+    assert_eq!(&serialized[12..16], &0_u32.to_le_bytes());
+
+    let roundtrip = TDigestMut::deserialize(&serialized, false).unwrap();
+    assert_eq!(roundtrip.total_weight(), 10_841);
+    assert_eq!(roundtrip.min_value(), Some(1.0));
+    assert_eq!(roundtrip.max_value(), Some(10.0));
+}
+
+#[test]
+fn test_updates_normalize_overfull_deserialized_centroid_tail() {
+    let path = serialization_test_data("cpp_generated_files", 
"tdigest_double_buf_n1000_cpp.sk");
+    let mut bytes = fs::read(path).unwrap();
+    assert_eq!(&bytes[8..12], &89_u32.to_le_bytes()); // num centroids
+    assert_eq!(&bytes[12..16], &160_u32.to_le_bytes()); // num buffered
+
+    // Extend the buffered tail of a real mixed C++ image just past the k=100 
producer threshold.
+    bytes[12..16].copy_from_slice(&841_u32.to_le_bytes());
+    for _ in 0..681 {
+        bytes.extend_from_slice(&1_000_f64.to_le_bytes());
+    }
+
+    let mut tdigest = TDigestMut::deserialize(&bytes, false).unwrap();
+    for _ in 0..10_000 {
+        tdigest.update(1_000.0);
+    }
+
+    assert_eq!(tdigest.total_weight(), 11_681);
+    assert_eq!(tdigest.min_value(), Some(1.0));
+    assert_eq!(tdigest.max_value(), Some(1_000.0));
+    // The overfull image must not disable future compression and let the 
centroid tail grow with
+    // every subsequent value.
+    assert!(tdigest.estimated_size() < 32_768);
+    let serialized = tdigest.serialize();
+    assert_eq!(&serialized[12..16], &0_u32.to_le_bytes());
+
+    let roundtrip = TDigestMut::deserialize(&serialized, false).unwrap();
+    assert_eq!(roundtrip.total_weight(), 11_681);
+    assert_eq!(roundtrip.min_value(), Some(1.0));
+    assert_eq!(roundtrip.max_value(), Some(1_000.0));
+}
+
+#[test]
+fn test_deserialize_rejects_truncated_large_payload_before_allocation() {
+    let mut tdigest = TDigestMut::new(10);
+    tdigest.update(0.0);
+    tdigest.update(1.0);
+    let mut bytes = tdigest.serialize();
+    bytes[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
+    bytes[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
+
+    assert!(TDigestMut::deserialize(&bytes, false).is_err());
+}
+
 #[test]
 fn test_large_weights_produce_finite_extreme_quantile() {
     let lower = f64::from_bits(f64::MAX.to_bits() - 1);
diff --git a/datasketches/tests/tdigest_test/sketch.rs 
b/datasketches/tests/tdigest_test/sketch.rs
index 3094194..56be846 100644
--- a/datasketches/tests/tdigest_test/sketch.rs
+++ b/datasketches/tests/tdigest_test/sketch.rs
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::mem::size_of;
+
 use datasketches::tdigest::TDigestMut;
 use googletest::assert_that;
 use googletest::prelude::eq;
@@ -75,6 +77,61 @@ fn test_maximum_k() {
     assert_eq!(tdigest.quantile(0.5), Some(1.0));
 }
 
+#[test]
+fn test_estimated_size_releases_initial_staging_after_compression() {
+    const K: u16 = 200;
+    const TARGET_CENTROIDS: usize = 410;
+    const MAX_UNMERGED: usize = TARGET_CENTROIDS * 4;
+
+    let inline_size = size_of::<TDigestMut>();
+    let mut tdigest = TDigestMut::new(K);
+    assert_eq!(tdigest.estimated_size(), inline_size);
+
+    for value in 0..MAX_UNMERGED {
+        tdigest.update(value as f64);
+    }
+    let size_before_compression = tdigest.estimated_size();
+    assert!(size_before_compression > inline_size);
+    tdigest.rank(0.5);
+    // Unit-weight centroids are twice the size of staged f64 values. Allow 
that representation
+    // change while guarding against retaining both backing allocations after 
compression.
+    assert!(tdigest.estimated_size() <= size_before_compression * 2 + 
inline_size);
+
+    for value in MAX_UNMERGED..10_000 {
+        tdigest.update(value as f64);
+    }
+    let size_before_compression = tdigest.estimated_size();
+    tdigest.rank(0.5);
+    assert!(tdigest.estimated_size() <= size_before_compression);
+
+    let mut left = TDigestMut::new(K);
+    for value in 0..8 {
+        left.update(value as f64);
+    }
+    let mut right = TDigestMut::new(K);
+    for value in 0..MAX_UNMERGED {
+        right.update(value as f64);
+    }
+    let right_size = right.estimated_size();
+    left.merge(&right);
+    assert_eq!(left.total_weight(), (MAX_UNMERGED + 8) as u64);
+    assert_eq!(right.total_weight(), MAX_UNMERGED as u64);
+    assert_eq!(right.estimated_size(), right_size);
+
+    let mut full_left = TDigestMut::new(K);
+    for value in 0..MAX_UNMERGED {
+        full_left.update(value as f64);
+    }
+    let combined_size = full_left.estimated_size() + right_size;
+    full_left.merge(&right);
+    assert_eq!(full_left.total_weight(), (MAX_UNMERGED * 2) as u64);
+    assert!(full_left.estimated_size() <= combined_size + combined_size / 2);
+
+    let mutable_size = full_left.estimated_size();
+    let frozen = full_left.freeze();
+    assert!(frozen.estimated_size() <= mutable_size);
+}
+
 #[test]
 fn test_many_values() {
     let n = 10000;


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

Reply via email to