Skip to content

Commit d35a105

Browse files
committed
chore: add VarianceAccumulator benchmark and tests, extract retract helper
Adds a criterion benchmark covering VarianceAccumulator::update_batch and retract_batch on null-free and 10%-null f64 batches, and unit tests covering null handling for update, retract, and retract underflow. Extracts the retract_batch loop body into a `retract` free function alongside the existing `update`. A null-free fast path for update_batch/retract_batch (iterating the values buffer directly when null_count() == 0, as COUNT(DISTINCT) does after #23956 and percentile_cont/median after #23954) was evaluated and rejected: back-to-back runs of the new benchmark show it is within noise (-0.3% to +1.6%), because the Welford update is a serial floating-point dependency chain whose latency hides the per-element validity check.
1 parent 043d97f commit d35a105

3 files changed

Lines changed: 175 additions & 16 deletions

File tree

datafusion/functions-aggregate/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,9 @@ harness = false
100100
name = "sliding_max"
101101
harness = false
102102

103+
[[bench]]
104+
name = "variance"
105+
harness = false
106+
103107
[features]
104108
force_hash_collisions = ["datafusion-common/force_hash_collisions"]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
use std::hint::black_box;
19+
use std::sync::Arc;
20+
21+
use arrow::array::{ArrayRef, Float64Array};
22+
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
23+
use datafusion_expr::Accumulator;
24+
use datafusion_functions_aggregate::variance::VarianceAccumulator;
25+
use datafusion_functions_aggregate_common::stats::StatsType;
26+
27+
const BATCH_SIZE: usize = 8192;
28+
29+
fn batch_array(null_stride: Option<usize>) -> ArrayRef {
30+
let values = (0..BATCH_SIZE)
31+
.map(|idx| {
32+
if null_stride.is_some_and(|stride| idx % stride == 0) {
33+
None
34+
} else {
35+
Some(idx as f64)
36+
}
37+
})
38+
.collect::<Vec<_>>();
39+
Arc::new(Float64Array::from(values)) as ArrayRef
40+
}
41+
42+
fn update_bench(c: &mut Criterion, name: &str, batch: &ArrayRef) {
43+
c.bench_function(name, |b| {
44+
b.iter(|| {
45+
let mut acc = VarianceAccumulator::try_new(StatsType::Sample).unwrap();
46+
acc.update_batch(std::slice::from_ref(batch)).unwrap();
47+
black_box(acc.evaluate().unwrap())
48+
})
49+
});
50+
}
51+
52+
fn retract_bench(c: &mut Criterion, name: &str, batch: &ArrayRef) {
53+
c.bench_function(name, |b| {
54+
b.iter_batched(
55+
|| {
56+
let mut acc = VarianceAccumulator::try_new(StatsType::Sample).unwrap();
57+
// Accumulate two batches so that retracting one leaves the
58+
// accumulator with rows remaining, as in a sliding window.
59+
acc.update_batch(std::slice::from_ref(batch)).unwrap();
60+
acc.update_batch(std::slice::from_ref(batch)).unwrap();
61+
acc
62+
},
63+
|mut acc| {
64+
acc.retract_batch(std::slice::from_ref(batch)).unwrap();
65+
black_box(acc.evaluate().unwrap())
66+
},
67+
BatchSize::SmallInput,
68+
)
69+
});
70+
}
71+
72+
fn variance_benchmark(c: &mut Criterion) {
73+
let no_nulls = batch_array(None);
74+
let with_nulls = batch_array(Some(10));
75+
76+
update_bench(c, "variance update_batch f64 no_nulls", &no_nulls);
77+
update_bench(c, "variance update_batch f64 with_nulls", &with_nulls);
78+
retract_bench(c, "variance retract_batch f64 no_nulls", &no_nulls);
79+
retract_bench(c, "variance retract_batch f64 with_nulls", &with_nulls);
80+
}
81+
82+
criterion_group!(benches, variance_benchmark);
83+
criterion_main!(benches);

datafusion/functions-aggregate/src/variance.rs

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,23 @@ fn update(count: u64, mean: f64, m2: f64, value: f64) -> (u64, f64, f64) {
326326
(new_count, new_mean, new_m2)
327327
}
328328

329+
/// Inverse of [`update`]: removes a previously accumulated value. Retracting
330+
/// from a state with one or zero values resets the state to empty.
331+
#[inline]
332+
fn retract(count: u64, mean: f64, m2: f64, value: f64) -> (u64, f64, f64) {
333+
if count <= 1 {
334+
return (0, 0.0, 0.0);
335+
}
336+
337+
let new_count = count - 1;
338+
let delta1 = mean - value;
339+
let new_mean = delta1 / new_count as f64 + mean;
340+
let delta2 = new_mean - value;
341+
let new_m2 = m2 - delta1 * delta2;
342+
343+
(new_count, new_mean, new_m2)
344+
}
345+
329346
impl Accumulator for VarianceAccumulator {
330347
fn state(&mut self) -> Result<Vec<ScalarValue>> {
331348
Ok(vec![
@@ -348,22 +365,8 @@ impl Accumulator for VarianceAccumulator {
348365
fn retract_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
349366
let arr = as_float64_array(&values[0])?;
350367
for value in arr.iter().flatten() {
351-
if self.count <= 1 {
352-
self.count = 0;
353-
self.mean = 0.0;
354-
self.m2 = 0.0;
355-
continue;
356-
}
357-
358-
let new_count = self.count - 1;
359-
let delta1 = self.mean - value;
360-
let new_mean = delta1 / new_count as f64 + self.mean;
361-
let delta2 = new_mean - value;
362-
let new_m2 = self.m2 - delta1 * delta2;
363-
364-
self.count -= 1;
365-
self.mean = new_mean;
366-
self.m2 = new_m2;
368+
(self.count, self.mean, self.m2) =
369+
retract(self.count, self.mean, self.m2, value)
367370
}
368371

369372
Ok(())
@@ -691,6 +694,75 @@ mod tests {
691694

692695
use super::*;
693696

697+
#[test]
698+
fn update_batch_ignores_nulls() -> Result<()> {
699+
// An array with nulls must accumulate the same values as a dense
700+
// array of its non-null values.
701+
let dense: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0]));
702+
let sparse: ArrayRef = Arc::new(Float64Array::from(vec![
703+
Some(1.0),
704+
None,
705+
Some(2.0),
706+
Some(3.0),
707+
None,
708+
Some(4.0),
709+
]));
710+
711+
let mut dense_acc = VarianceAccumulator::try_new(StatsType::Sample)?;
712+
dense_acc.update_batch(std::slice::from_ref(&dense))?;
713+
let mut sparse_acc = VarianceAccumulator::try_new(StatsType::Sample)?;
714+
sparse_acc.update_batch(std::slice::from_ref(&sparse))?;
715+
716+
// Sample variance of {1, 2, 3, 4} is 5/3 (all steps are exact in f64).
717+
assert_eq!(dense_acc.evaluate()?, ScalarValue::Float64(Some(5.0 / 3.0)));
718+
assert_eq!(dense_acc.evaluate()?, sparse_acc.evaluate()?);
719+
Ok(())
720+
}
721+
722+
#[test]
723+
fn retract_batch_ignores_nulls() -> Result<()> {
724+
let values: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0]));
725+
let dense_retract: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0]));
726+
let sparse_retract: ArrayRef =
727+
Arc::new(Float64Array::from(vec![Some(1.0), None, Some(2.0)]));
728+
729+
let mut dense_acc = VarianceAccumulator::try_new(StatsType::Sample)?;
730+
dense_acc.update_batch(std::slice::from_ref(&values))?;
731+
dense_acc.retract_batch(std::slice::from_ref(&dense_retract))?;
732+
let mut sparse_acc = VarianceAccumulator::try_new(StatsType::Sample)?;
733+
sparse_acc.update_batch(std::slice::from_ref(&values))?;
734+
sparse_acc.retract_batch(std::slice::from_ref(&sparse_retract))?;
735+
736+
// Sample variance of the remaining {3, 4} is 0.5 (all steps are exact
737+
// in f64).
738+
assert_eq!(dense_acc.evaluate()?, ScalarValue::Float64(Some(0.5)));
739+
assert_eq!(dense_acc.evaluate()?, sparse_acc.evaluate()?);
740+
Ok(())
741+
}
742+
743+
#[test]
744+
fn retract_batch_resets_when_underflowing() -> Result<()> {
745+
// Retracting more values than were accumulated resets to the empty
746+
// state, with or without nulls in the retracted batch.
747+
let values: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0]));
748+
let dense_retract: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0]));
749+
let sparse_retract: ArrayRef = Arc::new(Float64Array::from(vec![
750+
Some(1.0),
751+
None,
752+
Some(2.0),
753+
Some(3.0),
754+
]));
755+
756+
for retract in [&dense_retract, &sparse_retract] {
757+
let mut acc = VarianceAccumulator::try_new(StatsType::Sample)?;
758+
acc.update_batch(std::slice::from_ref(&values))?;
759+
acc.retract_batch(std::slice::from_ref(retract))?;
760+
assert_eq!(acc.get_count(), 0);
761+
assert_eq!(acc.evaluate()?, ScalarValue::Float64(None));
762+
}
763+
Ok(())
764+
}
765+
694766
#[test]
695767
fn test_groups_accumulator_merge_empty_states() -> Result<()> {
696768
let state_1 = vec![

0 commit comments

Comments
 (0)