ClickHouse and Friends (15) Why Group By Is So Fast
Before revealing the secrets of ClickHouse Group By, let’s talk about database performance comparison testing. In my view, what information should a “virtuous” performance comparison test provide?
First, respect objective facts. In what scenarios is x faster than y? Second, why is x faster than y?
If you’ve achieved both of the above, there’s one more important point: how long can x’s advantage last? Is it a long-term advantage from architecture, or just a quick optimization? Can it continue keeping up with its soul? If you just paste some flashy numbers, that’s not a benchmark — it’s benchmarket.
Okay, back to Group By. I believe many of you have experienced ClickHouse Group By’s excellent performance. This post analyzes why it’s fast. First, some reassurance: ClickHouse’s Group By doesn’t use fancy high-tech. It just found a relatively optimal approach.
One SQL
1
SELECT sum(number) FROM numbers(10) GROUP BY number % 3
We’ll use this simple SQL as a thread to see how ClickHouse implements Group By aggregation.
AggregatingTransform is the core of Group By’s high performance. In this example, modulo(number, 3) type is UInt8. For optimization, ClickHouse chooses to use arrays instead of hashtables for grouping. The distinction logic is in Interpreters/Aggregator.cpp
This is just an optimization branch for UInt8. How to optimize processing for other types? ClickHouse provides different hashtables for different types, quite grandiosely (code in Aggregator.h):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
using AggregatedDataWithUInt8Key = FixedImplicitZeroHashMapWithCalculatedSize<UInt8, AggregateDataPtr>; using AggregatedDataWithUInt16Key = FixedImplicitZeroHashMap<UInt16, AggregateDataPtr>; using AggregatedDataWithUInt32Key = HashMap<UInt32, AggregateDataPtr, HashCRC32<UInt32>>; using AggregatedDataWithUInt64Key = HashMap<UInt64, AggregateDataPtr, HashCRC32<UInt64>>; using AggregatedDataWithShortStringKey = StringHashMap<AggregateDataPtr>; using AggregatedDataWithStringKey = HashMapWithSavedHash<StringRef, AggregateDataPtr>; using AggregatedDataWithKeys128 = HashMap<UInt128, AggregateDataPtr, UInt128HashCRC32>; using AggregatedDataWithKeys256 = HashMap<DummyUInt256, AggregateDataPtr, UInt256HashCRC32>; using AggregatedDataWithUInt32KeyTwoLevel = TwoLevelHashMap<UInt32, AggregateDataPtr, HashCRC32<UInt32>>; using AggregatedDataWithUInt64KeyTwoLevel = TwoLevelHashMap<UInt64, AggregateDataPtr, HashCRC32<UInt64>>; using AggregatedDataWithShortStringKeyTwoLevel = TwoLevelStringHashMap<AggregateDataPtr>; using AggregatedDataWithStringKeyTwoLevel = TwoLevelHashMapWithSavedHash<StringRef, AggregateDataPtr>; using AggregatedDataWithKeys128TwoLevel = TwoLevelHashMap<UInt128, AggregateDataPtr, UInt128HashCRC32>; using AggregatedDataWithKeys256TwoLevel = TwoLevelHashMap<DummyUInt256, AggregateDataPtr, UInt256HashCRC32>; using AggregatedDataWithUInt64KeyHash64 = HashMap<UInt64, AggregateDataPtr, DefaultHash<UInt64>>; using AggregatedDataWithStringKeyHash64 = HashMapWithSavedHash<StringRef, AggregateDataPtr, StringRefHash64>; using AggregatedDataWithKeys128Hash64 = HashMap<UInt128, AggregateDataPtr, UInt128Hash>; using AggregatedDataWithKeys256Hash64 = HashMap<DummyUInt256, AggregateDataPtr, UInt256Hash>;
If we change to GROUP BY number*100000, it will choose AggregatedDataWithUInt64Key hashtable for grouping.
Moreover, ClickHouse provides a Two Level approach for handling situations with many grouping keys. Level1 first divides into large groups, Level2 small groups can be computed in parallel. For String types, hashtables are heavily optimized based on different lengths. Code in HashTable/StringHashMap.h
Summary
ClickHouse chooses an optimal hashtable or array based on the final type of Group By as the grouping basic data structure, keeping memory and computation as optimal as possible.
How is this “optimal solution” found? From test code, it’s through constant trying and testing verification — a strong bottom-up philosophy.