Repository navigation
Make aggregate accumulators storage column-based #956
Description
Is your feature request related to a problem or challenge? Please describe what you are trying to do.
Currently, the aggregate code keeps the states of each group by value, by storing it in a Vec together with the group by values.
For low-cardinality aggregates, this works OK, as there are only a limited number of keys and thus accumulators.
But for medium/high cardinality aggregate, the current way is inefficient:
- Higher memory usage, as each group by value has an extra
Vecwith a number ofAccumulatorItem, which adds at least 2 (
Box<dyn T>) + 3 (emptyVec) + 3 (initial ) = 8 pointers (64 bytes) of overhead per item when storing one aggregate per group (e.g. one of count/avg/sum). - Extra allocations while inserting new groups into the data structures and when returning states as
Vec<ScalarValue>, and some cloning. - Less cache efficient (because memory is scattered around)
- Requires more / expensive paths to convert into
VecandScalarValues and back to anArrays again.
This issue is for the Accumulator state only, but a similar thing could be done for the group_by_values with a similar
Describe the solution you'd like
We should define a trait that allows storing the required state in the accumulators in a contiguous/columnar manner.
The idea here is that the required state can be stored in a Vec-like container, where each item at the index contains the current state.
My proposal is to add an extra index to the methods, so the Accumulators can update the state at a certain index.
Some methods could be added to retrieve the entire state of the accumulator and/or convert the state values to array(s) in one go. If Arrow provides mutable Arrays in the future, it could avoid the extra conversion step back to an Array.
pub trait Accumulator: Send + Sync + Debug {
/// Initializes the state for a new group with a `index`
fn init_state(&self, index: usize);
/// Returns the state of the accumulator at the end of the accumulation.
// in the case of an average on which we track `sum` and `n`, this function should return a vector
// of two values, sum and n.
fn state(&self, index: usize) -> Result<Vec<ScalarValue>>;
/// updates the accumulator's state from a vector of scalars.
fn update(&mut self, index: usize, values: &[ScalarValue]) -> Result<()>;
/// updates the accumulator's state from a vector of arrays.
fn update_batch(&mut self, index: usize, values: &[ArrayRef]) -> Result<()> {
if values.is_empty() {
return Ok(());
};
(0..values[0].len()).try_for_each(|idx| {
let v = values
.iter()
.map(|array| ScalarValue::try_from_array(array, idx))
.collect::<Result<Vec<_>>>()?;
self.update(index, &v)
})
}
/// updates the accumulator's state from a vector of scalars.
fn merge(&mut self, index: usize, states: &[ScalarValue]) -> Result<()>;
/// updates the accumulator's state from a vector of states.
fn merge_batch(&mut self, index: usize, states: &[ArrayRef]) -> Result<()> {
if states.is_empty() {
return Ok(());
};
(0..states[0].len()).try_for_each(|idx| {
let v = states
.iter()
.map(|array| ScalarValue::try_from_array(array, index))
.collect::<Result<Vec<_>>>()?;
self.merge(index, &v)
})
}
/// returns its value based on its current state.
fn evaluate(&self, index: usize) -> Result<ScalarValue>;
}For Count this would be changed, most notably the change to store the state as Vec<u64>:
#[derive(Debug)]
struct CountAccumulator {
- count: u64,
+ count: Vec<u64>,
}
impl CountAccumulator {
/// new count accumulator
pub fn new() -> Self {
- Self { count: 0 }
+ Self { count: vec![] }
}
}
impl Accumulator for CountAccumulator {
- fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
+ fn init_state(&self, index: usize) {
+ assert_eq!(self.count.len(), index);
+ self.count.push(0);
+ }
+ fn update_batch(&mut self, index: usize, values: &[ArrayRef]) -> Result<()> {
let array = &values[0];
- self.count += (array.len() - array.data().null_count()) as u64;
+ self.count[index] += (array.len() - array.data().null_count()) as u64;
Ok(())
}
- fn update(&mut self, values: &[ScalarValue]) -> Result<()> {
+ fn update(&mut self, index: usize, values: &[ScalarValue]) -> Result<()> {
let value = &values[0];
if !value.is_null() {
- self.count += 1;
+ self.count[index] += 1;
}
Ok(())
}
- fn merge(&mut self, states: &[ScalarValue]) -> Result<()> {
+ fn merge(&mut self, index: usize, states: &[ScalarValue]) -> Result<()> {
let count = &states[0];
if let ScalarValue::UInt64(Some(delta)) = count {
- self.count += *delta;
+ self.count[index] += *delta;
} else {
unreachable!()
}
Ok(())
}
- fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> {
+ fn merge_batch(&mut self, index: usize, states: &[ArrayRef]) -> Result<()> {
let counts = states[0].as_any().downcast_ref::<UInt64Array>().unwrap();
let delta = &compute::sum(counts);
if let Some(d) = delta {
- self.count += *d;
+ self.count[index] += *d;
}
Ok(())
}
- fn state(&self) -> Result<Vec<ScalarValue>> {
- Ok(vec![ScalarValue::UInt64(Some(self.count))])
+ fn state(&self, index: usize) -> Result<Vec<ScalarValue>> {
+ Ok(vec![ScalarValue::UInt64(Some(self.count[index]))])
}
- fn evaluate(&self) -> Result<ScalarValue> {
- Ok(ScalarValue::UInt64(Some(self.count)))
+ fn evaluate(&self, index: usize) -> Result<ScalarValue> {
+ Ok(ScalarValue::UInt64(Some(self.count[index])))
}
}And this would be changed in the datastructure of hash aggregates (moving the state out of group state, to Accumulators:
+++ b/datafusion/src/physical_plan/hash_aggregate.rs
@@ -558,9 +558,6 @@ struct GroupState {
/// The actual group by values, one for each group column
group_by_values: Box<[ScalarValue]>,
- // Accumulator state, one for each aggregate
- accumulator_set: Vec<AccumulatorItem>,
-
/// scratch space used to collect indices for input rows in a
/// bach that have values to aggregate. Reset on each batch
indices: Vec<u32>,
@@ -578,6 +575,9 @@ struct Accumulators {
/// values: (hash, index into `group_states`)
map: RawTable<(u64, usize)>,
+ // Accumulator state, keeps state of each group state
+ accumulators: Vec<AccumulatorItem>,
+
/// State for each group
group_states: Vec<GroupState>,
}Describe alternatives you've considered
Additional context
Activity
Dandandan commentedon Sep 12, 2021
FYI @alamb this is a follow up proposal on improving the hash aggregate implementation.
alamb commentedon Sep 14, 2021
I think the idea of making accumulators (effectively) columnar is a great idea, FWIW. Mutable arrow arrays would also be awesome if/when that is available
ic4y commentedon Dec 6, 2021
@Dandandan
I am currently working out ways to solve the performance problem of high cardinality aggregation. Follow your method and tested it. I found that there is a certain performance improvement, but not ideal enough, only improved by about 10% under high base aggregation (I think it needs several times performance improvement likes doris and trino's performance under the high cardinality aggregation #1246). Do you have any better advice or other optimizations for these codes.
The relevant code segments are as follows.
Accumulators:
group_aggregate_batch:
Count:
more code:
https://github.com/ic4y/arrow-datafusion/blob/9d26b797f2e1565f7f65048ef1fc2ad2940d8f95/datafusion/src/physical_plan/hash_aggregate_fly.rs#L578-L596
alamb commentedon Dec 6, 2021
@ic4y
Did you do any performance profiling (using
pproffor example) to know where the time is being spent in your query? Is it the aggregate updates? Creating the final array? Something else?There isn't a lot of "low hanging fruit" left in the GroupByHash implementation -- to get "several times improvements" in performance I think we are likely to have to start special casing (e.g. for single column group by vs multi column group by, as well as vectorized aggregators for certain column types)
ic4y commentedon Dec 7, 2021
@alamb In the count sql, I use pprof to count the time consumed by the following methods. The long time consumption is mainly in
make slice,hashbrown get_mut,create_hashes. The time consumed by creating the final array and aggregate updates method is relatively small.Corresponding to the following code snippets
1、array slice and update_batch
2、hashbrown get_mut
3、create_hashes
4、create_batch_from_map
Dandandan commentedon Dec 7, 2021
Thanks for picking this up @ic4y
If you are getting a 10% improvement, this is already a fine achievement (if we don't slow down the low cardinality too much?)
The storage is one part of the story, as @alamb says it will require some changes in other places.
Some ideas here:
slicein the implementation, but rather append to aggregates directy (enabled by the change listed here)eq_arraybased on typed arrays.ScalarValuebut use values and arrays directy.ic4y commentedon Dec 14, 2021
@Dandandan Thank you very much for your suggestions
make slicesolution can indeed bring a certain performance improvement, but I tested on arrow2 and found that the performance ofarray.slicehas been greatly improved. Sosliceperformance is not a problem after using arrow2.eq_arrayI tried hashbrown'sget_each_mutmethod, but the performance did not change (through the analysis of PPROF, I found thateq_arraystill takes a lot of time). The code show as followsget_mutandeq_arraymethods by useing hashbrown's iter_hash to process them in two loops for making theeq_arrayvectorized execution (but there is no implement vectorized execution yet. I am not very familiar with rust's vectorized execution). the following code does separated most ofget_mutandeq_array, but how can we vectorize execution aneq_arrayin a loop?I really want to solve the performance problem of high cardinality aggregation, which has troubled me for a long time. Is there any problem with the above code? How can we further optimize? thanks
alamb commentedon Jun 26, 2023
This proposal is very similar in spirit to what @tustvold and I are proposing in #4973 (comment)
alamb commentedon Jun 30, 2023
(BTW there is lots of excitement on #4973 for anyone who wants to follow along)
Show reason for falling back to Spark when SMJ with join condition is…