Repository navigation
Description
Is your feature request related to a problem or challenge? Please describe what you are trying to do.
The performance of DataFusion is a very important feature, and aggregation is one of the core algorithms.
For example it features prominently in the ClickBench queries #5404
This ticket covers making it even faster.
Background
Prior Art
After #4924 we have a single GroupByHash implementation that uses the Arrow row format for group keys (good!) -- thank you @ozankabak and @mustafasrepo
However, the aggregator (e.g. the thing storing partial sums, counts, etc) is non ideal. Depending on the type of aggregate, it is either:
Box<dyn RowAccumulator>perBox<dyn Accumulator>
The GroupByHash operator manages the mapping from group key to state and passes a RowAccessor to the RowAccumulator if needed.
Groups are calculated once per batch, then a take kernel reshuffles (aka copies) everything and slices are passed to the RowAccumulator / Accumulator.
Proposal
Ditch the word-aligned row format for the state management and change the aggregator to:
trait Aggregator: MemoryConsumer {
/// Update aggregator state for given groups.
fn update_batch(&mut self, keys: Rows, batch: &RecordBatch)) -> Result<()>;
...
}Hence the aggregator will be dyn-dispatched ONCE per record batch and will keep its own internal state. This moves the key->state map from the [row_]hash.rs to the aggregators. We will provide tooling (macros or generics) to simplify the implementation and to avoid boilerplate code as much as possible.
Note that this also removes the take kernel since we think that it doesn't provide any performance improvements over iterating over the hashable rows and perform the aggregation row-by-row. We may bring the take handling back (as a "perform take if at least one aggregator wants that) if we can proof (via benchmarks) that this is desirable for certain aggregators, but we leave this out for now.
This also moves the memory consumer handling into the aggregator since the aggregator knows best how to split states and spill data.
Implementation Plan
TBD
Addtional Context
Note this was broken out of #2723 and is based on the wonderful writeup from @crepererum and @tustvold on #2723 (comment)
Activity
[-]Consolidate and improve the performance of Aggregators [/-][+]Consolidate and improve the performance of Aggregator[/+][-]Consolidate and improve the performance of Aggregator[/-][+]Consolidate and improve the performance of `Aggregator`[/+][-]Consolidate and improve the performance of `Aggregator`[/-][+]Improve the performance of `Aggregator`, grouping, aggregaton[/+]yjshen commentedon Mar 4, 2023
I'm curious about the proposed new Aggregator API. Do you know if it needs a hash table in each aggregator? I'm wondering because I'm a bit concerned about memory usage, especially for high cardinality aggregations.
Suppose keys are duplicated
ntimes during execution (wherenis the number of aggregators in the query). In that case, this could potentially lead to a significant increase in memory consumption, which might not be acceptable. What do you think about this?crepererum commentedon Mar 6, 2023
Yes, see #2723 (comment)
We're trading speed for slightly more memory usage here. We could probably avoid storing the actual key in the per-aggregator hash tables by either interning the key using another hash table (key -> ID) or by hashing the keys to a "wide enough" ID (like 128bit, also assuming that the hash function is actually proper).
yjshen commentedon Mar 6, 2023
So there will also be
ntimes hash probe, and rehash, besidesnmemory overhead. Won't this hinder performance? How is the prototype benchmarked?crepererum commentedon Mar 6, 2023
These are good questions.
Hashing an
u128ID should be very cheap since we don't need all this secure stuff anymore. So it's probably close toupper_word(id) ^ lower_word(id). The random access pattern could be a problem, but I think this will be hard to avoid in general, at least if you wanna have a maintainable, safe solution (in contrast to the untyped aligned row format that is currently used).There is no prototype yet, but a PR must run the groupby benchmarks.
That said, I am open to other suggestions. The issue is that the current system needs a redesign:
yjshen commentedon Mar 6, 2023
While I agree that we may need to revisit the current
Box<dyn Accumulator>approach when creating a fallback or default strategy, I do not believe we should combine all hash aggregate methods into one universal approach. Given that we are constructing an essential operator for a database execution engine, it is important to consider specialized implementations that offer performance benefits for handling different use cases.I view row hash as a fast lane for certain queries rather than a dead end. By efficiently executing queries through row hash, we can avoid slower or more general approaches. Furthermore, I am open to exploring other specialized aggregate implementations that offer performance benefits to handle more use cases quickly.
For example, Spark provides multiple hash aggregate implementations, including vectorized hash, hash, and object hash. Similarly, ClickHouse offers different implementation options for its hash table, based on key types. Utilizing these specialized implementations can optimize query performance without relying on a one-size-fits-all approach.
Regarding the proposed criteria for a fallback strategy, I believe that while it should be easy to add types and aggregations, not all internal implementations should be treated equally, or else we risk the entire engine running at the speed of UDAFs.
While it would be beneficial for the fallback strategy to work with memory accounting, it is not strictly necessary. Users can take risks when defining UDAFs, or we could even risk the less-used built-in functions.
I'm not convinced that data accesses need to be typed for aggregate implementations, which is entirely internal to database engines.
I understand that you were referring to the aligned row format, but it is possible to handle variable length fields using 'pointer swizzling.' Additionally, I don't believe that
medianis a performance-critical aggregate function that needs to be considered a first-class citizen for all aggregate strategies.tustvold commentedon Mar 6, 2023
What do people think about implementing the strategy as described here - #2723 (comment) and then revisiting the question of whether we keep additional fallback approaches based on empirical evidence as to their performance characteristics.
If this proves to outperform all the existing implementations, there is no harm in removing the other approaches, if it does not we can keep them
I think it is important to highlight that the proposal in #2723 (comment) is still a row hash approach, just using a different row encoding. Early benchmarks show it to be significantly faster
alamb commentedon Mar 6, 2023
I agree this should be benchmark results driven and the outcome should be as maintainable as possible. Given how performance critical this code is, I think performance is of higher priority than requiring strong typing (though of course it would be great if the resulting solution is strongly typed as well as high performance)
If we can't handle memory accounting for some reason, providing the user a choice to use potentially unlimited memory or faster performance, seems acceptable. I would be surprised, however, if we can't work a memory limit check in somehow and amortize its cost into the noise
yjshen commentedon Mar 7, 2023
@tustvold There appears to be a discrepancy between your proposal and my current situation. Can you clarify how I should interpret this?
Do you know if it needs a hash table in each aggregator? What does the internal state mean? Can you elaborate
If yes, what do you mean by "still a row hash approach"? Keys are rows, but aggregator states are not?
If not, why @crepererum said: "We're trading speed for slightly more memory usage here....", and why "in the per-aggregator hash tables by either interning...."
If the proposal is to remove all(word-aligned/compact) row formats inside DataFusion, use row-format from arrow-rs, and achieve a significant performance improvement, I completely vote for the change.
crepererum commentedon Mar 7, 2023
To clarify the situation around the row format:
@tustvold and I think:
The current compact aggregation looks roughly as following:
dyn RowAccumulatorand the untypedaggregation_bufferRowAccumulator::update_batch(this is a dyn dispatch per group per accumulator)Our proposal would be:
Accumulator. This is one dyn dispatch per accumulatorNow in our proposal you CAN still use reshuffle + flat state buffer, but in many cases it might be cheaper to just iterate over the input and do the aggregation directly (reshuffle VS hash multiple times).
So technically NOTHING would change when this proposal is implemented, just that the aggregator operator no longer needs to care about "is this a compact aggregator or a non-compact one" and that the buffers are owned by the aggregators instead of flying around. However the change provides a base to simplify aggregators or to make them even faster w/ more hacks (if we want).
yjshen commentedon Mar 7, 2023
I take it differently here; changing one single hash table to per-aggregator hash table is a fundamental change. After this change, we will theoretically:
If the current inefficiency comes from too much dyn dispatch, I would like to try JIT.
tustvold commentedon Mar 7, 2023
The cache locality is actually likely better as the aggregated values can be stored inline within the hash table as they are known at compile time. Similarly the row format stores consecutive rows contiguously in memory, and so you have very good locality from that perspective as well.
I think if we can get competitive performance without having to reach for JIT I think that is a massive win to maintainability. I originally reached for a JIT when creating the row format, and came to the conclusion it is a huge additional complexity that needs to yield order of magnitude performance improvements to justify itself. In the case of the row format the JIT approach was actually slower, than a correctly vectorised version. That's not to say we couldn't use a JIT, just it should definitely not be the first thing we try.
I see no reason to be fundamentalist here, we can support multiple approaches so long as they provide benefits to certain workloads. I suggest we move ahead with a POC unless you object and get some real performance data
35 remaining items
sunchao commentedon Jun 29, 2023
@Dandandan thanks, I agree that calculating hashes itself is probably not so critical to performances comparing to other hash table operations. We are doing some experiments to switch to vectorized hash table and see if it can deliver better performance. So far, without SIMD it's roughly the same as
hashbrownused by DF, and I'm really hoping by vectorizing each of the steps it can get us to the next level. Will share the result once I have some numbers.Dandandan commentedon Jun 29, 2023
Cool @sunchao very interested about what comes out of those experiments :)
mingmwang commentedon Jun 30, 2023
@alamb @tustvold @Dandandan
I did another POC based on the changes in #6657. The basic idea is to reduce the memory size of
GroupStateand avoid usingArc, Box, Vec, dyn Trait, etc. And the result is very exciting !!!Compared to the main branch, there is about 50% improvement, and for the high cardinality aggregation itself, the improvement is about 100%.
Test result:
Q17
Q18
And now, the update group stats method(hash, comparing and matching related operations) becomes the bottleneck:
Total evaluate agg input elapsed: 185
Total update group stats elapsed: 290376
Total update accumulators elapsed: 61192
Query 17 iteration 9 took 776.5 ms and returned 1 rows
Query 17 avg time: 776.81 ms
mingmwang commentedon Jun 30, 2023
Based on the test result, I would like do another POC and implement a new Accumulator framework. The Accumulator framework will follow your suggestions in the new Accumulator API proposal and will combine the normal Accumulator
and the RowAccumulator into a unified Accumulator framework.
RowLayoutto combine the grouping data and accumulator state togethergeneric.alamb commentedon Jun 30, 2023
Sounds like a great idea @mingmwang ❤️ -- Note I also got super excited about this project and I am in the middle of implementing a POC for the proposed approach -- you can see the structure I have so far here #6800. I am hoping to have some numbers ready to report today
If you also do a POC that will be great and we can compare the results and pick the one that shows the greatest promise.
alamb commentedon Jun 30, 2023
@mingmwang @Dandandan and @tustvold (and everyone else following along) here is a POC #6800
It follows similar pattern as you describe in #4973 (comment) and It also makes q17 go more than 2x faster (and has no
unsafecode or row format)!I would love feedback and thoughts on the approach
jhorstmann commentedon Jul 3, 2023
Clickbench query Q32 is also a very good benchmark of high cardinality grouping. It's also the only query that did not finish successfully when the benchmark results were last updated.
liurenjie1024 commentedon Jul 3, 2023
We have implemented a hash key to further reduce allocation for fix sized group key/state, which is inspired by clickhouse:
https://github.com/risingwavelabs/risingwave/blob/main/src/common/src/hash/key.rs
cc @mingmwang
vincev commentedon Jul 3, 2023
I am adding this comment here instead of opening a new issue as it looks the above PRs may fix/improve but please let me know if you would like a new issue.
I am experimenting with using DataFusion instead of Polars as query engine for dply-rs and I have noticed that group by median is more than ~20x slower (I run these a few times the timing is consistent):
The Polars version:
datafusion version:
This is using one year of data from the nyctaxi dataset.
The Datafusion code:
The Polars code:
alamb commentedon Jul 3, 2023
@vincev -- thank you Yes, I expect DataFusion will get significantly faster with the new grouping. However, the overall performance of the median aggregator will still have room to improve (someone will need to write a specialized vectorized implementation of
median)vincev commentedon Jul 4, 2023
@alamb spent a bit of time looking at the median aggregator, I was able to reduce the time from ~22secs to ~2secs by reducing the number of allocations:
I have created #6837.
mingmwang commentedon Jul 5, 2023
@alamb
I like your approach !!. The POC result is very promising, and the performance is better than my best version.