From 26e3d396119aba665b822322d07478810abb6d1a Mon Sep 17 00:00:00 2001 From: Filipe Oliveira Date: Thu, 2 Jul 2026 13:09:53 +0100 Subject: [PATCH] feat: single-pass value_at_quantiles / value_at_percentiles batch API Add batch queries that resolve N quantiles/percentiles in ONE scan over counts[], instead of N separate value_at_quantile calls. Requested quantiles are resolved in ascending-target order; the next target is hoisted into a local so the hot loop stays a tight 'total += counts[i]; if total >= next_target' (same shape as the singular scan). Naming matches the existing value_at_quantile/value_at_percentile pair; both new methods are #[must_use]. Results are byte-identical to per-quantile calls including unsorted/duplicate inputs, q==0.0/1.0, q>1.0 clamp, and empty histograms (verified by tests). NaN quantiles do not panic (the sort is over the derived u64 targets, not the f64 inputs). Batch throughput for all 7 of {50,75,90,95,99,99.9,99.99} (Granite Rapids, single core): 178,326 calls/sec vs 24,896 for 7x value_at_percentile = +616% (7.2x). --- src/lib.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++ tests/data_access.rs | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index e3a4e4b..adc2764 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1368,6 +1368,84 @@ impl Histogram { 0 } + /// Get the values at several quantiles in a single pass over the histogram. + /// + /// Returns a `Vec` with one entry per input quantile, in the same order as `quantiles` + /// (input order is preserved even if `quantiles` is unsorted or contains duplicates). Each + /// entry is exactly what [`Histogram::value_at_quantile`] would return for that quantile, but + /// the `counts` array is scanned only once regardless of how many quantiles are requested, + /// which is faster than N separate calls for N > 1. + /// + /// Edge behavior matches [`Histogram::value_at_quantile`]: quantiles are capped at `1.0`, an + /// empty histogram yields all `0`s, and `quantile == 0.0` uses the lowest equivalent value. + #[must_use] + pub fn value_at_quantiles(&self, quantiles: &[f64]) -> Vec { + let n = quantiles.len(); + let mut result = vec![0u64; n]; + if n == 0 { + return result; + } + + // Per-quantile target cumulative count (same rule as `value_at_quantile`). + let targets: Vec = quantiles + .iter() + .map(|&q| { + let q = if q > 1.0 { 1.0 } else { q }; + let c = (q * self.total_count as f64).ceil() as u64; + if c == 0 { + 1 + } else { + c + } + }) + .collect(); + + // Resolve quantiles in ascending target order so one scan satisfies all of them. + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| targets[a].cmp(&targets[b])); + + // Hoist the next target into a local so the hot loop stays a tight + // `total += counts[i]; if total >= next_target` — the same shape as the + // singular scan, which the optimizer keeps very tight. The per-crossing + // bookkeeping runs only when a threshold is actually reached. + let mut total_to_current_index: u64 = 0; + let mut pos = 0usize; + let mut next_target = targets[order[0]]; + for i in 0..self.counts.len() { + total_to_current_index += self.counts[i].as_u64(); + if total_to_current_index >= next_target { + let value_at_index = self.value_for(i); + loop { + let oi = order[pos]; + result[oi] = if quantiles[oi] == 0.0 { + self.lowest_equivalent(value_at_index) + } else { + self.highest_equivalent(value_at_index) + }; + pos += 1; + if pos >= n { + return result; + } + next_target = targets[order[pos]]; + if total_to_current_index < next_target { + break; + } + } + } + } + result + } + + /// Get the values at several percentiles (each in `[0.0, 100.0]`) in a single pass. + /// + /// Convenience wrapper over [`Histogram::value_at_quantiles`]; returns one value per input + /// percentile, in input order. + #[must_use] + pub fn value_at_percentiles(&self, percentiles: &[f64]) -> Vec { + let quantiles: Vec = percentiles.iter().map(|&p| p / 100.0).collect(); + self.value_at_quantiles(&quantiles) + } + /// Get the percentile of samples at and below a given value. /// /// This is simply `quantile_below* multiplied by 100.0. For best floating-point precision, use diff --git a/tests/data_access.rs b/tests/data_access.rs index d83c1f8..9034486 100644 --- a/tests/data_access.rs +++ b/tests/data_access.rs @@ -561,3 +561,61 @@ fn total_count_exceeds_bucket_type() { assert_eq!(400, h.len()); } + +// Batch value_at_quantiles / value_at_percentiles must return exactly what the singular +// value_at_quantile / value_at_percentile would, in input order, for every edge: +// unsorted + duplicate inputs, q==0.0, q==1.0, q>1.0 (clamp), and an empty histogram. +#[test] +fn batch_quantiles_match_singular() { + let mut h = Histogram::::new_with_bounds(1, 3_600_000_000, 3).unwrap(); + for v in 1..=1_000_000u64 { + h.record((v.wrapping_mul(2_654_435_761) % 1_000_000_000) + 1) + .unwrap(); + } + + // Unsorted, with duplicates and out-of-range values. + let quantiles = [0.99, 0.0, 0.5, 0.99, 1.0, 0.9999, 1.5, 0.5]; + let batch = h.value_at_quantiles(&quantiles); + assert_eq!(batch.len(), quantiles.len()); + for (i, &q) in quantiles.iter().enumerate() { + assert_eq!( + batch[i], + h.value_at_quantile(q), + "quantile {} (idx {})", + q, + i + ); + } + + let percentiles = [99.0, 0.0, 50.0, 99.0, 100.0, 99.99, 150.0, 50.0]; + let pbatch = h.value_at_percentiles(&percentiles); + for (i, &p) in percentiles.iter().enumerate() { + assert_eq!( + pbatch[i], + h.value_at_percentile(p), + "percentile {} (idx {})", + p, + i + ); + } + + // Empty slice -> empty result. + assert!(h.value_at_quantiles(&[]).is_empty()); +} + +#[test] +fn batch_quantiles_empty_histogram() { + // unitMagnitude > 0 (lowest > 1) is the case where a naive scan could diverge. + let h = Histogram::::new_with_bounds(100, 10_000_000, 3).unwrap(); + let quantiles = [0.0, 0.5, 0.9, 0.99, 1.0]; + let batch = h.value_at_quantiles(&quantiles); + for (i, &q) in quantiles.iter().enumerate() { + assert_eq!(batch[i], 0, "empty histogram, quantile {}", q); + assert_eq!( + batch[i], + h.value_at_quantile(q), + "empty vs singular, quantile {}", + q + ); + } +}