Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,84 @@ impl<T: Counter> Histogram<T> {
0
}

/// Get the values at several quantiles in a single pass over the histogram.
///
/// Returns a `Vec<u64>` 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<u64> {
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<u64> = 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<usize> = (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<u64> {
let quantiles: Vec<f64> = 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
Expand Down
58 changes: 58 additions & 0 deletions tests/data_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u64>::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::<u64>::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
);
}
}