Skip to main content

vortex_array/aggregate_fn/
accumulator.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::any::Any;
5
6use vortex_error::VortexExpect;
7use vortex_error::VortexResult;
8use vortex_error::vortex_bail;
9use vortex_error::vortex_ensure;
10use vortex_error::vortex_ensure_eq;
11
12use crate::ArrayRef;
13use crate::Columnar;
14use crate::ExecutionCtx;
15use crate::aggregate_fn::AggregateDTypes;
16use crate::aggregate_fn::AggregateFn;
17use crate::aggregate_fn::AggregateFnRef;
18use crate::aggregate_fn::AggregateFnVTable;
19use crate::aggregate_fn::session::AggregateFnSessionExt;
20use crate::columnar::AnyColumnar;
21use crate::dtype::DType;
22use crate::executor::max_iterations;
23use crate::expr::stats::Precision;
24use crate::expr::stats::Stat;
25use crate::expr::stats::StatsProvider;
26use crate::scalar::Scalar;
27
28/// Reference-counted type-erased accumulator.
29pub type AccumulatorRef = Box<dyn DynAccumulator>;
30
31/// An accumulator used for computing aggregates over an entire stream of arrays.
32pub struct Accumulator<V: AggregateFnVTable> {
33    /// The vtable of the aggregate function.
34    vtable: V,
35    /// The options of the aggregate function.
36    options: V::Options,
37    /// Type-erased aggregate function used for kernel dispatch.
38    aggregate_fn: AggregateFnRef,
39    /// The input, partial, and result dtypes lent to every vtable call.
40    dtypes: AggregateDTypes,
41    /// The partial state of the accumulator, with `None` as the empty group.
42    partial: Option<V::Partial>,
43}
44
45impl<V: AggregateFnVTable> Accumulator<V> {
46    pub fn try_new(vtable: V, options: V::Options, dtype: DType) -> VortexResult<Self> {
47        let dtypes = AggregateDTypes::try_new(&vtable, &options, dtype)?;
48
49        Ok(Self::from_dtypes(vtable, options, dtypes))
50    }
51
52    /// Build an accumulator over dtypes that are already resolved.
53    ///
54    /// A nested accumulator takes the dtypes its parent already derived, instead of resolving
55    /// them again for every batch.
56    pub fn from_dtypes(vtable: V, options: V::Options, dtypes: AggregateDTypes) -> Self {
57        let aggregate_fn = AggregateFn::new(vtable.clone(), options.clone()).erased();
58
59        Self {
60            vtable,
61            options,
62            aggregate_fn,
63            dtypes,
64            partial: None,
65        }
66    }
67
68    /// The state of a group with no accumulated values.
69    pub(crate) fn empty_partial(&self) -> VortexResult<V::Partial> {
70        self.vtable.empty_partial(self.dtypes.args(&self.options))
71    }
72
73    fn ensure_partial(&mut self) -> VortexResult<()> {
74        if self.partial.is_none() {
75            self.partial = Some(self.empty_partial()?);
76        }
77        Ok(())
78    }
79
80    pub(crate) fn fold_partial(&mut self, other: V::Partial) -> VortexResult<()> {
81        self.partial = Some(match self.partial.take() {
82            // The empty state is the identity of the merge.
83            None => other,
84            Some(current) => {
85                self.vtable
86                    .merge_partials(self.dtypes.args(&self.options), current, other)?
87            }
88        });
89        Ok(())
90    }
91
92    fn fold_partial_scalar(&mut self, scalar: &Scalar) -> VortexResult<()> {
93        let other = self
94            .vtable
95            .partial_from_scalar(self.dtypes.args(&self.options), scalar)?;
96        self.fold_partial(other)
97    }
98}
99
100/// A trait object for type-erased accumulators, used for dynamic dispatch when the aggregate
101/// function is not known at compile time.
102pub trait DynAccumulator: 'static + Send {
103    /// Accumulate a new array into the accumulator's state.
104    fn accumulate(&mut self, batch: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()>;
105
106    /// Drain another accumulator's state into this one, resetting `other`.
107    ///
108    /// The other accumulator must have been constructed for the same aggregate function,
109    /// options, and input dtype as this one.
110    fn merge_from(&mut self, other: &mut dyn DynAccumulator) -> VortexResult<()>;
111
112    /// Parse a partial scalar and merge it into this accumulator's state.
113    ///
114    /// The scalar must have the dtype reported by the vtable's `partial_dtype`, and represents
115    /// input following the input already accumulated.
116    fn combine_partials(&mut self, partial: &Scalar) -> VortexResult<()>;
117
118    /// Whether the accumulator's result is fully determined.
119    fn is_saturated(&self) -> bool;
120
121    /// Reset the accumulator's state to the empty group.
122    fn reset(&mut self);
123
124    /// Read the current partial state as a scalar without resetting it.
125    ///
126    /// The returned scalar has the dtype reported by the vtable's `partial_dtype`.
127    fn partial_scalar(&self) -> VortexResult<Scalar>;
128
129    /// Compute the final aggregate result as a scalar without resetting state.
130    fn final_scalar(&self) -> VortexResult<Scalar>;
131
132    /// Flush the accumulation state and return the partial aggregate result as a scalar.
133    ///
134    /// Resets the accumulator state back to the initial state.
135    fn flush(&mut self) -> VortexResult<Scalar>;
136
137    /// Finish the accumulation and return the final aggregate result as a scalar.
138    ///
139    /// Resets the accumulator state back to the initial state.
140    fn finish(&mut self) -> VortexResult<Scalar>;
141
142    /// Access the accumulator as [`Any`], so it can be downcast to a typed [`Accumulator`].
143    fn as_any_mut(&mut self) -> &mut dyn Any;
144}
145
146impl dyn DynAccumulator {
147    /// Downcast to the typed [`Accumulator`] of the aggregate vtable `V`.
148    pub fn downcast_mut<V: AggregateFnVTable>(&mut self) -> Option<&mut Accumulator<V>> {
149        self.as_any_mut().downcast_mut()
150    }
151}
152
153impl<V: AggregateFnVTable> DynAccumulator for Accumulator<V> {
154    fn accumulate(&mut self, batch: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()> {
155        if self.is_saturated() {
156            return Ok(());
157        }
158
159        vortex_ensure_eq!(batch.dtype(), &self.dtypes.dtype, "Input DType mismatch");
160
161        // 0. Legacy stats bridge: if this aggregate is still cached under a legacy Stat slot,
162        //    consume that exact stat before kernel dispatch or decode.
163        if let Some(stat) = Stat::from_aggregate_fn(&self.aggregate_fn)
164            && let Precision::Exact(partial) = batch.statistics().get(stat)
165        {
166            let partial = if partial.dtype() == &self.dtypes.partial_dtype {
167                partial
168            } else {
169                vortex_ensure!(
170                    partial
171                        .dtype()
172                        .eq_ignore_nullability(&self.dtypes.partial_dtype),
173                    "Aggregate {} read legacy stat {} with dtype {}, expected {}",
174                    self.aggregate_fn,
175                    stat,
176                    partial.dtype(),
177                    self.dtypes.partial_dtype,
178                );
179                partial.cast(&self.dtypes.partial_dtype)?
180            };
181            self.fold_partial_scalar(&partial)?;
182            return Ok(());
183        }
184
185        let session = ctx.session().clone();
186
187        // 1. Kernel registry first: a registered `(encoding, aggregate_fn)` kernel is strictly
188        //    more specific than the vtable's `try_accumulate` short-circuit. Checking the
189        //    registry first gives kernels for `Combined<V>` aggregates a chance to fire —
190        //    `Combined::try_accumulate` always returns true, so a later kernel check would be
191        //    unreachable.
192        {
193            let kernel = session
194                .aggregate_fns()
195                .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id());
196            if let Some(kernel) = kernel
197                && let Some(result) = kernel.aggregate(&self.aggregate_fn, batch, ctx)?
198            {
199                vortex_ensure_eq!(
200                    result.dtype(),
201                    &self.dtypes.partial_dtype,
202                    "Aggregate kernel returned the wrong partial dtype",
203                );
204                self.fold_partial_scalar(&result)?;
205                return Ok(());
206            }
207        }
208
209        // 2. Allow the vtable to short-circuit on the raw array before decompression.
210        self.ensure_partial()?;
211        let partial = self.partial.as_mut().vortex_expect("partial materialized");
212        if self
213            .vtable
214            .try_accumulate(self.dtypes.args(&self.options), partial, batch, ctx)?
215        {
216            return Ok(());
217        }
218
219        // 3. Iteratively check the registry against each intermediate encoding, executing one
220        //    step between checks. Mirrors the loop in `GroupedAccumulator::accumulate_list_view`.
221        //    Iteration 0 re-checks the initial encoding — a redundant HashMap miss, the price of
222        //    keeping the loop body uniform. Terminates on `AnyColumnar` (Canonical or Constant)
223        //    since the vtable's `accumulate(&Columnar)` handles both cases directly.
224        let mut batch = batch.clone();
225        for _ in 0..max_iterations() {
226            if batch.is::<AnyColumnar>() {
227                break;
228            }
229
230            if let Some(kernel) = session
231                .aggregate_fns()
232                .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id())
233                && let Some(result) = kernel.aggregate(&self.aggregate_fn, &batch, ctx)?
234            {
235                vortex_ensure_eq!(
236                    result.dtype(),
237                    &self.dtypes.partial_dtype,
238                    "Aggregate kernel returned the wrong partial dtype",
239                );
240                self.fold_partial_scalar(&result)?;
241                return Ok(());
242            }
243
244            batch = batch.execute(ctx)?;
245        }
246
247        // 4. Otherwise, execute the batch until it is columnar and accumulate it into the state.
248        let columnar = batch.execute::<Columnar>(ctx)?;
249
250        self.ensure_partial()?;
251        let partial = self.partial.as_mut().vortex_expect("partial materialized");
252        self.vtable
253            .accumulate(self.dtypes.args(&self.options), partial, &columnar, ctx)
254    }
255
256    fn merge_from(&mut self, other: &mut dyn DynAccumulator) -> VortexResult<()> {
257        let Some(other) = other.downcast_mut::<V>() else {
258            vortex_bail!(
259                "Cannot merge into a {} accumulator from an accumulator of a different aggregate",
260                self.aggregate_fn,
261            );
262        };
263        vortex_ensure!(
264            other.options == self.options && other.dtypes.dtype == self.dtypes.dtype,
265            "Cannot merge {} accumulators with different options or input dtypes",
266            self.aggregate_fn,
267        );
268        match other.partial.take() {
269            Some(partial) => self.fold_partial(partial),
270            None => Ok(()),
271        }
272    }
273
274    fn combine_partials(&mut self, partial: &Scalar) -> VortexResult<()> {
275        vortex_ensure_eq!(
276            partial.dtype(),
277            &self.dtypes.partial_dtype,
278            "Partial DType mismatch for {}",
279            self.aggregate_fn,
280        );
281        self.fold_partial_scalar(partial)
282    }
283
284    fn as_any_mut(&mut self) -> &mut dyn Any {
285        self
286    }
287
288    fn is_saturated(&self) -> bool {
289        self.partial.as_ref().is_some_and(|partial| {
290            self.vtable
291                .is_saturated(self.dtypes.args(&self.options), partial)
292        })
293    }
294
295    fn reset(&mut self) {
296        self.partial = None;
297    }
298
299    fn partial_scalar(&self) -> VortexResult<Scalar> {
300        let args = self.dtypes.args(&self.options);
301        let partial = match &self.partial {
302            Some(partial) => self.vtable.to_scalar(args, partial)?,
303            None => self.vtable.to_scalar(args, &self.empty_partial()?)?,
304        };
305
306        #[cfg(debug_assertions)]
307        {
308            vortex_ensure_eq!(
309                partial.dtype(),
310                args.partial_dtype,
311                "Aggregate returned incorrect DType on partial_scalar",
312            );
313        }
314
315        Ok(partial)
316    }
317
318    fn final_scalar(&self) -> VortexResult<Scalar> {
319        let args = self.dtypes.args(&self.options);
320        let result = match &self.partial {
321            Some(partial) => self.vtable.finalize_scalar(args, partial)?,
322            None => self.vtable.finalize_scalar(args, &self.empty_partial()?)?,
323        };
324
325        vortex_ensure_eq!(
326            result.dtype(),
327            args.return_dtype,
328            "Aggregate returned incorrect DType on final_scalar",
329        );
330
331        Ok(result)
332    }
333
334    fn flush(&mut self) -> VortexResult<Scalar> {
335        let partial = self.partial_scalar()?;
336        self.reset();
337        Ok(partial)
338    }
339
340    fn finish(&mut self) -> VortexResult<Scalar> {
341        let result = self.final_scalar()?;
342        self.reset();
343        Ok(result)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use vortex_buffer::buffer;
350    use vortex_error::VortexResult;
351    use vortex_session::SessionExt;
352    use vortex_session::VortexSession;
353
354    use crate::ArrayRef;
355    use crate::ExecutionCtx;
356    use crate::IntoArray;
357    use crate::VortexSessionExecute;
358    use crate::aggregate_fn::Accumulator;
359    use crate::aggregate_fn::AccumulatorRef;
360    use crate::aggregate_fn::AggregateFnRef;
361    use crate::aggregate_fn::AggregateFnVTable;
362    use crate::aggregate_fn::DynAccumulator;
363    use crate::aggregate_fn::NumericalAggregateOpts;
364    use crate::aggregate_fn::combined::Combined;
365    use crate::aggregate_fn::combined::PairOptions;
366    use crate::aggregate_fn::fns::mean::Mean;
367    use crate::aggregate_fn::fns::min::Min;
368    use crate::aggregate_fn::fns::sum::Sum;
369    use crate::aggregate_fn::kernels::DynAggregateKernel;
370    use crate::aggregate_fn::session::AggregateFnSession;
371    use crate::array::VTable;
372    use crate::arrays::Dict;
373    use crate::arrays::DictArray;
374    use crate::dtype::DType;
375    use crate::dtype::Nullability;
376    use crate::dtype::PType;
377    use crate::expr::stats::Precision;
378    use crate::expr::stats::Stat;
379    use crate::scalar::Scalar;
380    use crate::scalar::ScalarValue;
381
382    /// Mean partial sentinel `{sum: 42.0, count: 1}` — distinguishable from the
383    /// natural fan-out result `{sum: 7.0, count: 1}` that `Combined::try_accumulate`
384    /// would produce for `dict_of_seven()`.
385    #[derive(Debug)]
386    struct SentinelMeanPartialKernel;
387    impl DynAggregateKernel for SentinelMeanPartialKernel {
388        fn aggregate(
389            &self,
390            _aggregate_fn: &AggregateFnRef,
391            _batch: &ArrayRef,
392            _ctx: &mut ExecutionCtx,
393        ) -> VortexResult<Option<Scalar>> {
394            Ok(Some(sentinel_partial()))
395        }
396    }
397
398    /// Returns `Ok(None)` => kernel declined, dispatch falls through.
399    #[derive(Debug)]
400    struct DeclineKernel;
401    impl DynAggregateKernel for DeclineKernel {
402        fn aggregate(
403            &self,
404            _aggregate_fn: &AggregateFnRef,
405            _batch: &ArrayRef,
406            _ctx: &mut ExecutionCtx,
407        ) -> VortexResult<Option<Scalar>> {
408            Ok(None)
409        }
410    }
411
412    /// A sentinel sum of `42.0`, distinguishable from `dict_of_seven()`'s natural sum of `7.0`.
413    #[derive(Debug)]
414    struct SentinelSumPartialKernel;
415    impl DynAggregateKernel for SentinelSumPartialKernel {
416        fn aggregate(
417            &self,
418            _aggregate_fn: &AggregateFnRef,
419            _batch: &ArrayRef,
420            _ctx: &mut ExecutionCtx,
421        ) -> VortexResult<Option<Scalar>> {
422            Ok(Some(Scalar::primitive(42.0f64, Nullability::Nullable)))
423        }
424    }
425
426    fn fresh_session() -> VortexSession {
427        crate::array_session()
428    }
429
430    fn dict_of_seven() -> ArrayRef {
431        DictArray::try_new(buffer![0u32].into_array(), buffer![7.0f64].into_array())
432            .expect("valid dictionary")
433            .into_array()
434    }
435
436    fn mean_f64_accumulator() -> VortexResult<Accumulator<Combined<Mean>>> {
437        let dtype = DType::Primitive(PType::F64, Nullability::NonNullable);
438        Accumulator::try_new(
439            Mean::combined(),
440            PairOptions(
441                NumericalAggregateOpts::default(),
442                NumericalAggregateOpts::default(),
443            ),
444            dtype,
445        )
446    }
447
448    fn sentinel_partial() -> Scalar {
449        let acc = mean_f64_accumulator().expect("build accumulator");
450        let sum = Scalar::primitive(42.0f64, Nullability::Nullable);
451        let count = Scalar::primitive(1u64, Nullability::NonNullable);
452        Scalar::struct_(acc.dtypes.partial_dtype, vec![sum, count])
453    }
454
455    /// Kernel registered for `(Dict, Combined<Mean>)` fires in preference to
456    /// `Combined::try_accumulate`'s fan-out path — proves the dispatch reorder.
457    #[test]
458    fn combined_kernel_fires() -> VortexResult<()> {
459        static KERNEL: SentinelMeanPartialKernel = SentinelMeanPartialKernel;
460        let session = fresh_session();
461        session
462            .get::<AggregateFnSession>()
463            .register_aggregate_kernel(Dict.id(), Some(Mean::combined().id()), &KERNEL);
464        let mut ctx = session.create_execution_ctx();
465
466        let mut acc = mean_f64_accumulator()?;
467        acc.accumulate(&dict_of_seven(), &mut ctx)?;
468        let partial = acc.flush()?;
469
470        let s = partial.as_struct();
471        assert_eq!(
472            s.field("sum").unwrap().as_primitive().as_::<f64>(),
473            Some(42.0)
474        );
475        assert_eq!(
476            s.field("count").unwrap().as_primitive().as_::<u64>(),
477            Some(1)
478        );
479        Ok(())
480    }
481
482    /// Kernel returns `Ok(None)` => dispatch falls through to `Combined::try_accumulate`'s
483    /// natural fan-out. The natural partial is `{sum: 7.0, count: 1}`.
484    #[test]
485    fn fallback_when_kernel_declines() -> VortexResult<()> {
486        static KERNEL: DeclineKernel = DeclineKernel;
487        let session = fresh_session();
488        session
489            .get::<AggregateFnSession>()
490            .register_aggregate_kernel(Dict.id(), Some(Mean::combined().id()), &KERNEL);
491        let mut ctx = session.create_execution_ctx();
492
493        let mut acc = mean_f64_accumulator()?;
494        acc.accumulate(&dict_of_seven(), &mut ctx)?;
495        let partial = acc.flush()?;
496
497        let s = partial.as_struct();
498        assert_eq!(
499            s.field("sum").unwrap().as_primitive().as_::<f64>(),
500            Some(7.0)
501        );
502        assert_eq!(
503            s.field("count").unwrap().as_primitive().as_::<u64>(),
504            Some(1)
505        );
506        Ok(())
507    }
508
509    /// A kernel registered for the inner `(Dict, Sum)` child fires when accumulating a
510    /// Dict batch through `Combined<Mean>`. This is the reusable-primitive case the
511    /// refactor enables: no `(Dict, Combined<Mean>)` kernel is needed.
512    #[test]
513    fn child_kernel_fires_through_combined() -> VortexResult<()> {
514        static KERNEL: SentinelSumPartialKernel = SentinelSumPartialKernel;
515        let session = fresh_session();
516        session
517            .get::<AggregateFnSession>()
518            .register_aggregate_kernel(Dict.id(), Some(Sum.id()), &KERNEL);
519        let mut ctx = session.create_execution_ctx();
520
521        let mut acc = mean_f64_accumulator()?;
522        acc.accumulate(&dict_of_seven(), &mut ctx)?;
523        let partial = acc.flush()?;
524
525        let s = partial.as_struct();
526        // `Sum` child returned the sentinel 42.0 — proves the (Dict, Sum) kernel fired
527        // via `Combined<Mean>`'s fan-out. `Count`'s native `try_accumulate` reads the
528        // batch's valid_count, so count is the real 1.
529        assert_eq!(
530            s.field("sum").unwrap().as_primitive().as_::<f64>(),
531            Some(42.0)
532        );
533        assert_eq!(
534            s.field("count").unwrap().as_primitive().as_::<u64>(),
535            Some(1)
536        );
537        Ok(())
538    }
539
540    #[test]
541    fn cached_sum_precedes_encoding_kernel() -> VortexResult<()> {
542        static KERNEL: SentinelSumPartialKernel = SentinelSumPartialKernel;
543        let session = fresh_session();
544        session
545            .get::<AggregateFnSession>()
546            .register_aggregate_kernel(Dict.id(), Some(Sum.id()), &KERNEL);
547        let mut ctx = session.create_execution_ctx();
548
549        let batch = dict_of_seven();
550        batch
551            .statistics()
552            .set(Stat::Sum, Precision::Exact(ScalarValue::from(11.0f64)));
553
554        let dtype = DType::Primitive(PType::F64, Nullability::NonNullable);
555        let mut acc = Accumulator::try_new(Sum, NumericalAggregateOpts::default(), dtype)?;
556        acc.accumulate(&batch, &mut ctx)?;
557
558        assert_eq!(acc.finish()?.as_primitive().as_::<f64>(), Some(11.0));
559        Ok(())
560    }
561
562    fn sum_i32_accumulator(options: NumericalAggregateOpts) -> VortexResult<AccumulatorRef> {
563        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
564        Ok(Box::new(Accumulator::try_new(Sum, options, dtype)?))
565    }
566
567    #[test]
568    fn merge_from_drains_other_accumulator() -> VortexResult<()> {
569        let mut ctx = fresh_session().create_execution_ctx();
570        let mut global = sum_i32_accumulator(NumericalAggregateOpts::default())?;
571        let mut local = sum_i32_accumulator(NumericalAggregateOpts::default())?;
572
573        global.accumulate(&buffer![10i32, 20].into_array(), &mut ctx)?;
574        local.accumulate(&buffer![5i32].into_array(), &mut ctx)?;
575        global.merge_from(local.as_mut())?;
576
577        assert_eq!(
578            global.finish()?,
579            Scalar::primitive(35i64, Nullability::Nullable)
580        );
581        // The merged-from accumulator is reset back to the empty state.
582        assert_eq!(
583            local.finish()?,
584            Scalar::primitive(0i64, Nullability::Nullable)
585        );
586        Ok(())
587    }
588
589    #[test]
590    fn merge_from_rejects_a_different_aggregate() -> VortexResult<()> {
591        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
592        let mut sum = sum_i32_accumulator(NumericalAggregateOpts::default())?;
593        let mut min: AccumulatorRef = Box::new(Accumulator::try_new(
594            Min,
595            NumericalAggregateOpts::default(),
596            dtype,
597        )?);
598
599        assert!(sum.merge_from(min.as_mut()).is_err());
600        Ok(())
601    }
602
603    #[test]
604    fn merge_from_rejects_mismatched_options() -> VortexResult<()> {
605        let mut skipping = sum_i32_accumulator(NumericalAggregateOpts::skip_nans())?;
606        let mut including = sum_i32_accumulator(NumericalAggregateOpts::include_nans())?;
607
608        assert!(skipping.merge_from(including.as_mut()).is_err());
609        Ok(())
610    }
611
612    #[test]
613    fn merge_from_combines_child_accumulators() -> VortexResult<()> {
614        let mut ctx = fresh_session().create_execution_ctx();
615        let mut global: AccumulatorRef = Box::new(mean_f64_accumulator()?);
616        let mut local: AccumulatorRef = Box::new(mean_f64_accumulator()?);
617
618        global.accumulate(&buffer![1.0f64, 2.0].into_array(), &mut ctx)?;
619        local.accumulate(&buffer![6.0f64].into_array(), &mut ctx)?;
620        global.merge_from(local.as_mut())?;
621
622        assert_eq!(global.finish()?.as_primitive().as_::<f64>(), Some(3.0));
623        Ok(())
624    }
625}