ClickHouse Keeps Aggregate State in an Arena and Destroys It by Hand
ClickHouse aggregate functions own no data: their state lives in a bump-allocated Arena as raw bytes, created with placement new and destroyed explicitly, one contiguous block per group.
TL;DR: An
IAggregateFunctionis a description, not a container. The bytes it accumulates live in anArenaas rawchar *, are created with placementnew, and must be destroyed with an explicit~Data()call — the arena frees the memory but never runs the destructor. For aGROUP BYwith three aggregate functions, all three states are carved out of one allocation, padded so each is correctly aligned.
Every relational engine has to answer the same question: where does the intermediate result of an aggregate live?
Row-at-a-time engines answer “in the row”. A GROUP BY over a hash table
answers “in the map’s value”. A columnar engine has to pick something else,
because the group state can be arbitrarily large — a groupArray state is
bigger than the whole input — and because the state has to be mergeable,
serializable, and movable between threads.
ClickHouse’s answer is the most austere one possible: the aggregate function object contains no data at all. It is a description — a name, a list of argument types, a set of parameters — plus the code that knows how to manipulate a state. The state itself is a blob of bytes in an arena.
The interface says this out loud:
// src/AggregateFunctions/IAggregateFunction.h:74
/** Aggregate functions interface.
* Instances of classes with this interface do not contain the data itself for aggregation,
* but contain only metadata (description) of the aggregate function,
* as well as methods for creating, deleting and working with data.
* The data resulting from the aggregation (intermediate computing states) is stored in other objects
* (which can be created in some memory pool),
* and IAggregateFunction is the external interface for manipulating them.
*/
class IAggregateFunction : public std::enable_shared_from_this<IAggregateFunction>, public IResolvedFunction
{
public:
IAggregateFunction(const DataTypes & argument_types_, const Array & parameters_, const DataTypePtr & result_type_)
: argument_types(argument_types_)
, parameters(parameters_)
, result_type(result_type_)
{}Note the vocabulary: create, delete, manipulate. The function is the tool; the arena holds the workpiece.
The state is a char *, and the function has to be told where
The entire state-handling contract is four virtual methods. AggregateDataPtr
is not a smart pointer and not a class — it is char *:
// src/AggregateFunctions/IAggregateFunction_fwd.h:9
using AggregateDataPtr = char *;
using AggregateDataPtrs = VectorWithMemoryTracking<AggregateDataPtr>;
using ConstAggregateDataPtr = const char *;and the interface:
// src/AggregateFunctions/IAggregateFunction.h:175
/** Create empty data for aggregation with `placement new` at the specified location.
* You will have to destroy them using the `destroy` method.
*/
virtual void create(AggregateDataPtr __restrict place) const = 0;
/// Delete data for aggregation.
virtual void destroy(AggregateDataPtr __restrict place) const noexcept = 0;
/// Delete all combinator states that were used after combinator -State.
/// For example for uniqArrayStateForEachMap(...) it will destroy
/// states that were created by combinators Map and ForEach.
/// It's needed because ColumnAggregateFunction in the result will be
/// responsible only for destruction of states that were created
/// by aggregate function and all combinators before -State combinator.
virtual void destroyUpToState(AggregateDataPtr __restrict place) const noexcept
{
destroy(place);
}
/// It is not necessary to delete data.
virtual bool hasTrivialDestructor() const = 0;
/// Get `sizeof` of structure with data.
virtual size_t sizeOfData() const = 0;
/// How the data structure should be aligned.
virtual size_t alignOfData() const = 0;The comment on create is the whole design in one sentence: placement new at
the specified location, and you will have to destroy them using the destroy
method. Nobody will do it for you. There is no std::unique_ptr here,
because a unique_ptr would want to delete the memory, and the memory does
not belong to the function.
The default implementation: placement new, explicit destructor
Most aggregate functions get create/destroy/sizeOfData/alignOfData for
free from a CRTP helper. The state type is a template parameter; the function
type is the Derived parameter, which is what makes the add call
non-virtual (see below):
// src/AggregateFunctions/IAggregateFunction.h:965
/// Implements several methods for manipulation with data. T - type of structure with data for aggregation.
template <typename T, typename Derived>
class IAggregateFunctionDataHelper : public IAggregateFunctionHelper<Derived>
{
protected:
using Data = T;
static Data & data(AggregateDataPtr __restrict place) { return *reinterpret_cast<Data *>(place); } /// NOLINT(readability-non-const-parameter)
static const Data & data(ConstAggregateDataPtr __restrict place) { return *reinterpret_cast<const Data *>(place); }
public:
// Derived class can `override` this to flag that DateTime64 is not supported.
static constexpr bool DateTime64Supported = true;
IAggregateFunctionDataHelper(const DataTypes & argument_types_, const Array & parameters_, const DataTypePtr & result_type_)
: IAggregateFunctionHelper<Derived>(argument_types_, parameters_, result_type_)
{
/// To prevent derived classes changing the destroy() without updating hasTrivialDestructor() to match it
/// Enforce that either both of them are changed or none are
constexpr bool declares_destroy_and_has_trivial_destructor =
std::is_same_v<decltype(&IAggregateFunctionDataHelper::destroy), decltype(&Derived::destroy)> ==
std::is_same_v<decltype(&IAggregateFunctionDataHelper::hasTrivialDestructor), decltype(&Derived::hasTrivialDestructor)>;
static_assert(declares_destroy_and_has_trivial_destructor,
"destroy() and hasTrivialDestructor() methods of an aggregate function must be either both overridden or not");
}
void create(AggregateDataPtr __restrict place) const override /// NOLINT
{
new (place) Data;
}
void destroy(AggregateDataPtr __restrict place) const noexcept override
{
data(place).~Data();
}
bool hasTrivialDestructor() const override
{
return std::is_trivially_destructible_v<Data>;
}
size_t sizeOfData() const override
{
return sizeof(Data);
}
size_t alignOfData() const override
{
return alignof(Data);
}Three things worth staring at.
new (place) Data; — placement new. No allocation happens; the object is
constructed in the arena’s memory. For a sum over UInt64 that’s eight
bytes with a UInt64 inside. For groupArray it is a vector, and the
vector’s own heap allocations still go through the normal allocator (or the
arena, if the function asks for it).
data(place).~Data(); — the destructor called explicitly, by hand. The
arena will free the bytes eventually, but free does not run
~std::vector. Without this line, every groupArray group would leak its
buffer. This is the price of the “no data in the object” design: lifetime is
split in two. The bytes are owned by the arena; the object is owned by
whoever calls destroy.
The static_assert. A function that overrides destroy but forgets
hasTrivialDestructor is a latent use-after-free: the aggregator would skip
calling destroy because it believes the destructor is trivial. The assert
makes the two overrides a package deal, enforced at compile time.
hasTrivialDestructor is a performance knob, not a formality
hasTrivialDestructor exists so the hot path can skip a destructor call.
Consider the destructor of the result column:
// src/Columns/ColumnAggregateFunction.cpp:88
ColumnAggregateFunction::~ColumnAggregateFunction()
{
if (!func->hasTrivialDestructor() && !src)
for (auto * val : data)
func->destroy(val);
}For sum over UInt64 the state is trivially destructible, so the loop is
skipped entirely — the memory is already gone with the arena. For groupArray
the loop runs and frees every buffer. A single if decides whether a whole
query pays for a destructor walk.
The same flag is aggregated across all functions of a query. The Aggregator
computes it once at construction:
// src/Interpreters/Aggregator.cpp:801
if (!params.aggregates[i].function->hasTrivialDestructor())
all_aggregates_has_trivial_destructor = false;and uses it to decide whether the whole result set needs an explicit teardown pass or can simply be dropped.
One block per group, with manual alignment padding
Here is the part that surprises people. The states of all aggregate
functions of a query are laid out side by side in one allocation per
group. The Aggregator computes the offsets once, at construction time, and
pads between states so that each one lands on its required alignment:
// src/Interpreters/Aggregator.cpp:775
// aggregate_states will be aligned as below:
// |<-- state_1 -->|<-- pad_1 -->|<-- state_2 -->|<-- pad_2 -->| .....
//
// pad_N will be used to match alignment requirement for each next state.
// The address of state_1 is aligned based on maximum alignment requirements in states
for (size_t i = 0; i < params.aggregates_size; ++i)
{
offsets_of_aggregate_states[i] = total_size_of_aggregate_states;
total_size_of_aggregate_states += params.aggregates[i].function->sizeOfData();
// aggregate states are aligned based on maximum requirement
align_aggregate_states = std::max(align_aggregate_states, params.aggregates[i].function->alignOfData());
// If not the last aggregate_state, we need pad it so that next aggregate_state will be aligned.
if (i + 1 < params.aggregates_size)
{
size_t alignment_of_next_state = params.aggregates[i + 1].function->alignOfData();
if ((alignment_of_next_state & (alignment_of_next_state - 1)) != 0)
throw Exception(ErrorCodes::LOGICAL_ERROR, "`alignOfData` is not 2^N");
/// Extend total_size to next alignment requirement
/// Add padding by rounding up 'total_size_of_aggregate_states' to be a multiplier of alignment_of_next_state.
total_size_of_aggregate_states = (total_size_of_aggregate_states + alignment_of_next_state - 1) / alignment_of_next_state * alignment_of_next_state;
}
if (!params.aggregates[i].function->hasTrivialDestructor())
all_aggregates_has_trivial_destructor = false;
}The diagram in the source is the whole story. For SELECT count(), sum(x), avg(y) FROM t:
offsets_of_aggregate_states[0] = 0
|<-- count state -->|<-- pad -->|<-- sum state -->|<-- pad -->|<-- avg state -->|
8 bytes to 8 8 bytes to 8 16 bytesAll three states are carved out of a single arena allocation, so a group
costs one pointer in the hash table, not three. The hash table’s mapped value
is just an AggregateDataPtr:
// src/Interpreters/AggregatedData.h:40
using AggregatedDataWithUInt32Key = HashMap<UInt32, AggregateDataPtr, HashCRC32<UInt32>>;
using AggregatedDataWithUInt64Key = HashMap<UInt64, AggregateDataPtr, HashCRC32<UInt64>>;and the allocation happens once per new group, from the arena:
// src/Interpreters/Aggregator.cpp:1943
if (!aggregate_data)
aggregate_data = aggregates_pool->alignedAlloc(total_size_of_aggregate_states, align_aggregate_states);
createAggregateStates(aggregate_data, use_compiled_functions);
emplace_result.setMapped(aggregate_data);alignedAlloc is a bump allocator with alignment support — no free list, no
per-object header:
// src/Common/Arena.h:227
/// Get piece of memory with alignment
char * alignedAlloc(size_t size, size_t alignment)
{
used_bytes += size;
if (unlikely(head.empty() || size > head.remaining()))
addMemoryChunk(size, alignment);
do
{
void * head_pos = head.pos;
size_t space = head.end - head.pos;
auto * res = static_cast<char *>(std::align(alignment, size, head_pos, space));
if (res)
{
head.pos = static_cast<char *>(head_pos);
head.pos += size;
ASAN_UNPOISON_MEMORY_REGION(res, size + pad_right);
return res;
}
addMemoryChunk(size, alignment);
} while (true);
}The arena’s own documentation is blunt about the consequence: freeing parts of
data is not possible. Which means an individual group’s state block cannot be
returned to the allocator when the group is evicted — only the destructor can
be run. That is exactly the split-ownership situation destroy exists to
handle.
create is exception-safe, by rolling back
Because create constructs an object, it can throw. If sum’s state is
created and then groupArray’s state fails to allocate, the first state must
not be leaked — and must not be double-destroyed later. The Aggregator
handles this with an explicit rollback loop:
// src/Interpreters/Aggregator.cpp:999
template <bool skip_compiled_aggregate_functions>
void Aggregator::createAggregateStates(AggregateDataPtr & aggregate_data) const
{
for (size_t j = 0; j < params.aggregates_size; ++j)
{
if constexpr (skip_compiled_aggregate_functions)
if (is_aggregate_function_compiled[j])
continue;
try
{
/** An exception may occur if there is a shortage of memory.
* In order that then everything is properly destroyed, we "roll back" some of the created states.
* The code is not very convenient.
*/
aggregate_functions[j]->create(aggregate_data + offsets_of_aggregate_states[j]);
}
catch (...)
{
for (size_t rollback_j = 0; rollback_j < j; ++rollback_j)
{
if constexpr (skip_compiled_aggregate_functions)
if (is_aggregate_function_compiled[j])
continue;
aggregate_functions[rollback_j]->destroy(aggregate_data + offsets_of_aggregate_states[rollback_j]);
}
throw;
}
}
}The comment The code is not very convenient is a 20-year-old grudge that still holds. This is the cost of manual lifetime management: every construction site has to know how to unwind.
The virtual call per row, and how it is removed
Now the performance question. add is virtual, and it is called once per
row per aggregate function:
// src/AggregateFunctions/IAggregateFunction.h:203
/** Adds a value into aggregation data on which place points to.
* columns points to columns containing arguments of aggregation function.
* row_num is number of row which should be added.
* Additional parameter arena should be used instead of standard memory allocator if the addition requires memory allocation.
*/
virtual void add(AggregateDataPtr __restrict place, const IColumn ** columns, size_t row_num, Arena * arena) const = 0;An indirect call per row per function is not catastrophic — the hash lookup that precedes it is more expensive — but it is not free either, and it prevents inlining of the accumulation itself into the loop.
ClickHouse’s fix is the addBatch family. The virtual dispatch happens
once per block, and then a tight loop calls the concrete add directly
through the CRTP Derived type, so the compiler can inline it:
// src/AggregateFunctions/IAggregateFunction.h:352
/** Contains a loop with calls to "add" function. You can collect arguments into array "places"
* and do a single call to "addBatch" for devirtualization and inlining.
*/
virtual void addBatch( /// NOLINT
size_t row_begin,
size_t row_end,
AggregateDataPtr * places,
size_t place_offset,
const IColumn ** columns,
Arena * arena,
ssize_t if_argument_pos = -1) const = 0;and the generic implementation in the CRTP helper:
// src/AggregateFunctions/IAggregateFunction.h:637
void addBatch( /// NOLINT
size_t row_begin,
size_t row_end,
AggregateDataPtr * places,
size_t place_offset,
const IColumn ** columns,
Arena * arena,
ssize_t if_argument_pos = -1) const override
{
if (if_argument_pos >= 0)
{
const auto & flags = assert_cast<const ColumnUInt8 &>(*columns[if_argument_pos]).getData();
for (size_t i = row_begin; i < row_end; ++i)
{
if (flags[i] && places[i])
static_cast<const Derived *>(this)->add(places[i] + place_offset, columns, i, arena);
}
}
else
{
for (size_t i = row_begin; i < row_end; ++i)
if (places[i])
static_cast<const Derived *>(this)->add(places[i] + place_offset, columns, i, arena);
}
}static_cast<const Derived *>(this)->add(...) is a static call. The
compiler knows exactly which function it is, so it can inline the whole
accumulation — for sum over UInt64 that is literally state->sum += column->getData()[row].
The -If combinator gets its own treatment: instead of branching on the
condition inside the loop, the condition column is passed as
if_argument_pos and the nested function’s addBatch reads it. One
ColumnUInt8 load and a branch per row, no virtual dispatch.
For the really hot case — grouping by a UInt8 — there is a dedicated
implementation that keeps 256 separate state sets so that the per-group
states don’t alias:
// src/AggregateFunctions/IAggregateFunction.h:1016
void addBatchLookupTable8(
size_t row_begin,
size_t row_end,
AggregateDataPtr * map,
size_t place_offset,
std::function<void(AggregateDataPtr &)> init,
const UInt8 * key,
const IColumn ** columns,
Arena * arena) const final
{
const Derived & func = *static_cast<const Derived *>(this);
/// If the function is complex or too large, use more generic algorithm.
if (func.allocatesMemoryInArena() || sizeof(Data) > 16 || func.sizeOfData() != sizeof(Data))
{
IAggregateFunctionHelper<Derived>::addBatchLookupTable8(row_begin, row_end, map, place_offset, init, key, columns, arena);
return;
}
/// Will use UNROLL_COUNT number of lookup tables.
static constexpr size_t UNROLL_COUNT = 4;
std::unique_ptr<Data[]> places{new Data[256 * UNROLL_COUNT]};
bool has_data[256 * UNROLL_COUNT]{}; /// Separate flags array to avoid heavy initialization.
Note the guard: sizeof(Data) > 16 — if the state is bigger than 16 bytes,
this trick is not worth it, and the generic path is used instead. The comment
Separate flags array to avoid heavy initialization is a small performance
note that says a lot: even a bool[1024] on the stack is not free.
The function pointer escape hatch
For the merging code paths — where a function object is not statically known
— ClickHouse exposes the add as a raw function pointer:
// src/AggregateFunctions/IAggregateFunction.h:343
/** The inner loop that uses the function pointer is better than using the virtual function.
* The reason is that in the case of virtual functions GCC 5.1.2 generates code,
* which, at each iteration of the loop, reloads the function address (the offset value in the virtual function table) from memory to the register.
* This gives a performance drop on simple queries around 12%.
* After the appearance of better compilers, the code can be removed.
*/
using AddFunc = void (*)(const IAggregateFunction *, AggregateDataPtr, const IColumn **, size_t, Arena *);
virtual AddFunc getAddressOfAddFunction() const = 0;The comment is a fossil: it talks about GCC 5.1.2 and a 12% regression from the vtable reload. The mechanism survives because the shape survives: a plain function pointer in a register is one indirection; a virtual call is an indirection plus a vtable load that the compiler may not be able to hoist out of a loop.
The CRTP helper supplies it with a free function that does the downcast:
// src/AggregateFunctions/IAggregateFunction.h:570
/// Implement method to obtain an address of 'add' function.
template <typename Derived>
class IAggregateFunctionHelper : public IAggregateFunction
{
private:
static void addFree(const IAggregateFunction * that, AggregateDataPtr __restrict place, const IColumn ** columns, size_t row_num, Arena * arena)
{
static_cast<const Derived &>(*that).add(place, columns, row_num, arena);
}static_cast<const Derived &>(*that) — the pointer is typed as the base, but
the object really is the derived type, so the call is statically dispatched
and inlinable.
The merging algorithms grab the pointer once and call it in a loop. The sorted-merge code, for instance, caches it next to the state buffer:
// src/Processors/Merges/Algorithms/SummingSortedAlgorithm.cpp:45
{
/// An aggregate function 'sumWithOverflow' or 'sumMapWithOverflow' for summing.
AggregateFunctionPtr function;
IAggregateFunction::AddFunc add_function = nullptr;
VectorWithMemoryTracking<size_t> column_numbers;
IColumn * merged_column = nullptr;
AlignedBuffer state;
bool created = false;// src/Processors/Merges/Algorithms/SummingSortedAlgorithm.cpp:75
void init(AggregateFunctionPtr function_, bool is_simple_agg_func_type_ = false)
{
function = std::move(function_);
add_function = function->getAddressOfAddFunction();
state.reset(function->sizeOfData(), function->alignOfData());
is_simple_agg_func_type = is_simple_agg_func_type_;
}and then calls it directly, once per row, with no virtual dispatch at all:
// src/Processors/Merges/Algorithms/SummingSortedAlgorithm.cpp:922
if (desc.column_numbers.size() == 1)
{
auto & col = raw_columns[desc.column_numbers[0]];
desc.add_function(desc.function.get(), desc.state.data(), &col, row, def.arena.get());
}
else
{
// Gather all source columns into a vector
ColumnRawPtrs column_ptrs(desc.column_numbers.size());
for (size_t i = 0; i < desc.column_numbers.size(); ++i)
column_ptrs[i] = raw_columns[desc.column_numbers[i]];
desc.add_function(desc.function.get(), desc.state.data(), column_ptrs.data(), row, def.arena.get());
}Note state.reset(function->sizeOfData(), function->alignOfData()) — the
same two numbers the aggregator uses to lay out states, here used to size a
single stack buffer. The merge path has exactly one state, so it does not
need the arena at all.
Merging: two states, one destination
merge is where two partially-aggregated states combine. The public entry
point is deliberately non-virtual and does one thing before delegating:
// src/AggregateFunctions/IAggregateFunction.h:221
/// Merges state (on which place points to) with other state of current aggregation function.
/// Non-virtual public entry point that asserts the source and destination states do not alias
/// (self-merging is undefined for aggregate functions whose `mergeImpl` reallocates the destination's
/// internal storage and then reads from it; e.g. `quantilesExact`, `groupArray`, `sequenceMatch`).
/// All overrides must be done on `mergeImpl` below.
void merge(AggregateDataPtr __restrict place, ConstAggregateDataPtr rhs, Arena * arena) const
{
chassert(place != rhs, "IAggregateFunction::merge called with the same source and destination state");
mergeImpl(place, rhs, arena);
}
/// Implementation of `merge` for a specific aggregate function. Must not be called directly;
/// use `merge` (or the batch variants) which performs the self-aliasing check.
virtual void mergeImpl(AggregateDataPtr __restrict place, ConstAggregateDataPtr rhs, Arena * arena) const = 0;The split exists because self-merging is genuinely dangerous. If mergeImpl
reallocates the destination’s internal buffer and then reads from the source,
and source and destination are the same object, you have freed the buffer you
are about to read. quantilesExact sorts its array in place;
groupArray appends and may reallocate. The chassert catches the mistake at
the boundary where it can still be caught — every override only has to
implement mergeImpl and cannot forget the check.
The merge path is what makes distributed aggregation and AggregatingMergeTree
work: a shard produces states, they are serialized, shipped, and merged
somewhere else. For that to be possible at all, the state must be
self-contained bytes — which is exactly what the arena design gives you.
Finalizing: one call per group, then the state dies
At the end of the aggregation, each group’s state is converted into a value. That happens exactly once per group, per aggregate function:
// src/AggregateFunctions/IAggregateFunction.h:304
/// Inserts results into a column. This method might modify the state (e.g.
/// sort an array), so must be called once, from single thread. The state
/// must remain valid though, and the subsequent calls to add/merge/
/// insertResultInto must work correctly. This kind of call sequence occurs
/// in `runningAccumulate`, or when calculating an aggregate function as a
/// window function.
virtual void insertResultInto(AggregateDataPtr __restrict place, IColumn & to, Arena * arena) const = 0;“might modify the state (e.g. sort an array)” — this is why finalize is
allowed to mutate, and why the interface says must be called once, from
single thread. The aggregator does it in a loop over groups:
// src/Interpreters/Aggregator.cpp:4071
try
{
/// Insert final values of aggregate functions into columns.
for (; insert_i < params.aggregates_size; ++insert_i)
aggregate_functions[insert_i]->insertResultInto(
mapped + offsets_of_aggregate_states[insert_i],
*final_aggregate_columns[insert_i],
arena);
}
catch (...)
{
exception = std::current_exception();
}
/** Destroy states that are no longer needed. This loop does not throw.
*
* For functions with -State combinator we destroy only states of all combinators that are used
* after -State, because the ownership of the rest states is transferred to ColumnAggregateFunction
* and ColumnAggregateFunction will take care.
*
* But it's only for states that has been transferred to ColumnAggregateFunction
* before exception has been thrown;
*/
for (size_t destroy_i = 0; destroy_i < params.aggregates_size; ++destroy_i)
{
if (destroy_i < insert_i)
aggregate_functions[destroy_i]->destroyUpToState(mapped + offsets_of_aggregate_states[destroy_i]);
else
aggregate_functions[destroy_i]->destroy(mapped + offsets_of_aggregate_states[destroy_i]);
}
/// Mark the cell as destroyed so it will not be destroyed in destructor.
mapped = nullptr;Three subtleties in thirty lines.
The try/catch around finalization. If one group’s insertResultInto
throws, the states of the remaining groups must still be destroyed, and the
groups already finalized must not be destroyed twice. The insert_i /
destroy_i split encodes exactly that: groups before the failure were
finalized (and their states are dead), groups after it were not.
destroyUpToState vs destroy. This is the -State combinator’s
ownership transfer. sumState(x) returns the state rather than the value,
and the state is handed to a ColumnAggregateFunction in the result block.
That column now owns the bytes. But combinators applied after -State — say
-ForEach or -Map — allocated additional state that the column knows
nothing about. So the aggregator destroys only “up to” the -State boundary
and lets the column own the rest.
mapped = nullptr; — the hash table cell is nulled so the aggregator’s own
destructor will not try to destroy the state a second time. The cell is
marked dead.
The state type is a data type, and its name is its identity
One more piece of the puzzle. A function’s state is not just bytes; it is a
type that can appear as a column. DataTypeAggregateFunction wraps the
function pointer, the argument types, the parameters, and an optional
version:
// src/DataTypes/DataTypeAggregateFunction.cpp:109
String DataTypeAggregateFunction::getNameImpl(bool with_version) const
{
WriteBufferFromOwnString stream;
stream << "AggregateFunction(";
/// If aggregate function does not support versioning its version is 0 and is not printed.
auto data_type_version = getVersion();
if (with_version && data_type_version)
stream << data_type_version << ", ";
stream << function->getName();
stream << formatParameters(parameters);
for (const auto & argument_type : argument_types)
stream << ", " << argument_type->getName();
stream << ')';
return stream.str();
}The name of the type — AggregateFunction(sum, UInt64) — is what
distinguishes one aggregate function instantiation from another. And the hash
of the type mixes in exactly the same components:
// src/DataTypes/DataTypeAggregateFunction.cpp:219
void DataTypeAggregateFunction::updateHashImpl(SipHash & hash) const
{
hash.update(getFunctionName());
hash.update(parameters.size());
for (const auto & param : parameters)
hash.update(param.getType());
hash.update(argument_types.size());
for (const auto & arg_type : argument_types)
arg_type->updateHash(hash);
if (version)
hash.update(*version);
hash.update(static_cast<UInt8>(function->getStateVariant()));
}Function name, parameters, argument types, version, and state variant. Two
aggregate functions have interchangeable states if and only if all five match
— which is what haveSameStateRepresentation checks, and what lets
uniqMerge consume a column of AggregateFunction(uniq, UInt64) states
without any runtime type inspection of the bytes.
The default state type is produced by the function itself:
// src/AggregateFunctions/IAggregateFunction.cpp:16
DataTypePtr IAggregateFunction::getStateType() const
{
return std::make_shared<DataTypeAggregateFunction>(shared_from_this(), argument_types, parameters);
}shared_from_this() — the function is held by shared_ptr because the state
type holds a reference to it, and the type must be able to construct new
states (for ColumnAggregateFunction::createColumn).
The trade-offs
What this buys you.
- One allocation per group. All the aggregate functions of a query share a
single arena block. The hash table stores one pointer per group, not one
per function. For a
GROUP BYwith eight aggregates, that is an 8x reduction in the per-group pointer traffic and a much better locality profile: the states a query touches together are adjacent in memory. - Cheap state transfer. Because a state is self-contained bytes, shipping it over the network for distributed aggregation is just “serialize these bytes”. No object graph, no pointers to fix up, no reconstruction logic.
- Agnosticism about the state. The aggregator never inspects a state. It
knows only
sizeOfData()andalignOfData(), and it can therefore handle agroupArraystate of 10 MB or acountstate of 8 bytes with exactly the same code. - Bounded dispatch overhead.
addBatchturns a per-row virtual call into a per-block virtual call plus an inlinable loop.
What it costs you.
- Manual lifetime management. Every
createsite needs a rollback path, and every state needs a matchingdestroy. Thestatic_assertondestroy/hasTrivialDestructorexists precisely because the compiler cannot check this for you. - No reclamation. The arena cannot free an individual group’s block. When
the
topKoptimization evicts groups, only the destructor runs; the bytes stay until the whole arena is dropped. For a high-cardinality group-by that means the arena’s high-water mark, not the live-set size, is what you pay for. - A confusingly split ownership. The bytes belong to the arena, the object
belongs to whoever called
create, and after-Statethe object belongs to aColumnAggregateFunctionin the result block. ThedestroyUpToStatedance is the price of that three-way split. - The state type is a type. Which means the identity of an aggregate function instance leaks into the schema, into the wire format, and into the hash of a column — a cost paid at the type level for a benefit that is purely about the bytes.
The whole design can be summarized in one sentence: the function is the code, the arena is the memory, and the interface is the contract that tells you who has to call the destructor.