DataFusion's DISTINCT Is a GROUP BY, and approx_distinct Is a HyperLogLog Over Hashes
Exact DISTINCT is rewritten into a GROUP BY over all columns, while approx_distinct folds a 64-bit hash of each value into 16384 registers
TL;DR:
SELECT DISTINCT a, bis not a separate operator — the optimizer rewrites it intoGROUP BY a, b, which is the same hash-table machinery as any other aggregation. When the exact answer does not fit in memory,approx_distinctswaps the exact set for a 16384-register HyperLogLog, and the sketch is indexed by the hash of the value: hashing is what gives you a uniform 64-bit key for free, and a collision just costs you a little accuracy.
Part one: DISTINCT is a GROUP BY
The instinct is to treat DISTINCT as its own operator with its own
algorithm. DataFusion does not, and the reason is that a distinct set is a
grouping: SELECT DISTINCT a, b returns exactly the groups of
GROUP BY a, b, one row each.
The optimizer rule says so outright:
// datafusion/optimizer/src/replace_distinct_aggregate.rs:31-64
/// Optimizer that replaces logical [[Distinct]] with a logical [[Aggregate]]
///
/// ```text
/// SELECT DISTINCT a, b FROM tab
/// ```
///
/// Into
/// ```text
/// SELECT a, b FROM tab GROUP BY a, b
/// ```
// replace_distinct_aggregate.rs:87-127
LogicalPlan::Distinct(Distinct::All(input)) => {
let group_expr = expand_wildcard(input.schema(), &input, None)?;
if group_expr.is_empty() {
// Special case: there are no columns to group by, so we can't replace it by a group by
// however, we can replace it by LIMIT 1 because there is either no output or a single empty row
return Ok(Transformed::yes(Arc::unwrap_or_clone(input)));
}
let field_count = input.schema().fields().len();
for dep in input.schema().functional_dependencies().iter() {
// If the input is already unique on all of its columns (e.g.
// it is a GROUP BY over exactly these columns), the DISTINCT
// is a no-op and we can simply remove it. The dependency mode
// must be `Single`: a `Multi` dependence (e.g. a former key
// downgraded by a join) means equal rows may occur multiple
// times, so the DISTINCT still has work to do.
if dep.mode == Dependency::Single
&& dep.source_indices.len() >= field_count
&& dep.source_indices[..field_count]
.iter()
.enumerate()
.all(|(idx, f_idx)| idx == *f_idx)
{
return Ok(Transformed::yes(Arc::unwrap_or_clone(input)));
}
}
// Replace with aggregation:
let aggr_plan = LogicalPlan::Aggregate(Aggregate::try_new(
input,
group_expr,
vec![],
)?);Three cases in twenty lines:
- No columns at all (a table with no columns): there is at most one row,
so the answer is
LIMIT 1:
// replace_distinct_aggregate.rs:93-101
if group_expr.is_empty() {
// Special case: there are no columns to group by, so we can't replace it by a group by
// however, we can replace it by LIMIT 1 because there is either no output or a single empty row
return Ok(Transformed::yes(LogicalPlan::Limit(Limit {
skip: None,
fetch: Some(Box::new(lit(1i64))),
input,
})));
}- The input is already unique on all columns (functional dependency with
mode
Single):DISTINCTis a no-op and is deleted. TheSinglevsMultidistinction matters — aMultidependency means “these columns are functionally determined by something else”, so equal rows can still repeat. - Everything else: rewrite to
GROUP BYon the expanded wildcard.
DISTINCT ON is more interesting, because it is not a plain grouping:
// replace_distinct_aggregate.rs:41-51
/// On the other hand, for a `DISTINCT ON` query the replacement is
/// a bit more involved and effectively converts
/// ```text
/// SELECT DISTINCT ON (a) b FROM tab ORDER BY a DESC, c
/// ```
/// into
/// ```text
/// SELECT b FROM (
/// SELECT a, FIRST_VALUE(b ORDER BY a DESC, c) AS b
/// FROM tab
/// GROUP BY a
/// )
/// ORDER BY a DESC
/// ```
DISTINCT ON (a) becomes GROUP BY a plus first_value(b ORDER BY ...).
The group key supplies the uniqueness; the ordering-sensitive aggregate
supplies the payload.
count(DISTINCT x) becomes two phases
count(DISTINCT b) cannot be a plain group by — you need the distinct values
of b within each group a. DataFusion splits it into an inner group by
over the finer grain and an outer aggregate over the coarse one:
// datafusion/optimizer/src/single_distinct_to_groupby.rs:40-56
/// single distinct to group by optimizer rule
/// ```text
/// Before:
/// SELECT a, count(DISTINCT b), sum(c)
/// FROM t
/// GROUP BY a
///
/// After:
/// SELECT a, count(alias1), sum(alias2)
/// FROM (
/// SELECT a, b as alias1, sum(c) as alias2
/// FROM t
/// GROUP BY a, b
/// )
/// GROUP BY a
/// ```
The inner GROUP BY a, b produces one row per (a, distinct b), and the
outer count(alias1) counts them. Note that the inner group by has no
aggregate at all — it is a pure distinct — which is why the outer aggregate’s
input is one row per distinct value.
The rule is careful about when the rewrite pays. It only applies if every
distinct aggregate shares the same argument expression, and it refuses to
extend the rewrite to a plan that only qualifies because of a non-distinct
count:
// single_distinct_to_groupby.rs:176-196
/// Whether the rewrite is worth extending to a plan that only qualifies because
/// of the non-distinct `count`.
///
/// The rewrite is not free: every other aggregate moves down to the inner group
/// by, which has a row per `(group, distinct value)` pair rather than per group,
/// and keeps its state at that finer grain. What pays for that is taking the
/// distinct aggregate off `GroupsAccumulatorAdapter` and its one boxed
/// accumulator per group. An aggregate that already has a specialized
/// `GroupsAccumulator` never went near the adapter, so there is nothing to buy
/// and only the inner group by to pay for.
DISTINCT on an idempotent aggregate is free
Not every DISTINCT needs rewriting. min(DISTINCT x) is min(x), because
the merge is idempotent. DataFusion encodes this as a property of the
aggregate function itself:
// datafusion/optimizer/src/eliminate_aggregate_distinct.rs:30-52
/// Optimizer rule that removes a `DISTINCT` modifier that cannot change the
/// result of the aggregate it is attached to.
///
/// `min`, `max`, `bool_and`, `bit_or` and friends have an idempotent merge, so
/// `min(DISTINCT x)` and `min(x)` return the same value. ...
and approx_distinct declares itself insensitive to duplicates, because
folding a value into a HyperLogLog twice changes nothing:
// datafusion/functions-aggregate/src/approx_distinct.rs:903-906
fn distinct_handling(&self) -> DistinctHandling {
// Updating an HLL register with a value already seen is a no-op.
DistinctHandling::Insensitive
}The physical side: early termination
Once DISTINCT is a GROUP BY with no aggregates, the physical plan can stop
as soon as it has enough groups. AggregateExec has a predicate for exactly
that shape:
// datafusion/physical-plan/src/aggregates/mod.rs:1775-1813
/// true, if this Aggregate has a group-by with no required or explicit ordering,
/// no filtering and no aggregate expressions
/// This method qualifies the use of the LimitedDistinctAggregation rewrite rule
/// on an AggregateExec.
pub fn is_unordered_unfiltered_group_by_distinct(&self) -> bool {and the stream checks the soft limit after every batch:
// datafusion/physical-plan/src/aggregates/single_stream.rs:105-114
/// After each input batch, the stream checks whether the soft limit has been
/// reached. If so, it emits the accumulated groups and stops reading input.
///
/// This operator does not guarantee an exact limit because a single batch can
/// cross the threshold. The downstream limit operator enforces the exact result
/// size.
So SELECT DISTINCT x FROM t LIMIT 10 becomes a group-by that reads one
batch, maybe two, and stops.
Part two: when the exact set does not fit
The exact rewrite has a cost: the hash table holds one entry per distinct
value, forever. SELECT DISTINCT user_id FROM events over ten billion events
needs a table with as many entries as there are distinct users. That is a
memory wall, not a CPU wall.
approx_distinct replaces the exact set with a fixed-size sketch. The sketch
is a HyperLogLog with 2^14 registers:
// datafusion/functions-aggregate/src/hyperloglog.rs:26-48
/// Specifically, like Redis's version, this HLL structure uses
/// 2**14 = 16384 registers, which means the standard error is
/// 1.04/(16384**0.5) = 0.8125%. Unlike Redis, the register takes
/// up full [`u8`] size instead of a raw int* and thus saves some
/// tricky bit shifting techniques used in the original version.
/// This results in a memory usage increase from 12Kib to 16Kib.
/// Also only the dense version is adopted, so there's no automatic
/// conversion, largely to simplify the code.
const HLL_P: usize = 14_usize;
/// The number of bits of the hash value used determining the number of leading zeros
const HLL_Q: usize = 64_usize - HLL_P;
pub(crate) const NUM_REGISTERS: usize = 1_usize << HLL_P;
/// Mask to obtain index into the registers
const HLL_P_MASK: u64 = (NUM_REGISTERS as u64) - 1;16 KiB per group, fixed, regardless of cardinality.
The trick: hash the value, don’t encode it
Here is the core design decision, and it is the one worth understanding.
A HyperLogLog needs, for each distinct value, a uniformly random 64-bit key. It then splits that key into a register index (the low 14 bits) and a “rho” (the position of the first zero bit among the remaining 50 bits).
The value itself cannot be that key. It might be a 200-byte string, a
Decimal256, a nested Struct. There is no canonical 64-bit projection of
those. You could invent one, but it would either be lossy in a way you have to
reason about, or it would cost an allocation per value.
Hashing gives you the key for free, and DataFusion already has a vectorized hashing engine, because hash aggregation needs exactly the same thing:
// datafusion/functions-aggregate/src/approx_distinct.rs:203-225
impl Accumulator for HLLAccumulator {
fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
let array = values[0].as_ref();
self.hashes.clear();
self.hashes.resize(array.len(), 0);
create_hashes([array], &HLL_HASH_STATE, &mut self.hashes)?;
match array.logical_nulls() {
None => {
for &hash in &self.hashes {
self.hll.add_hashed(hash);
}
}
Some(nulls) => {
for row in 0..array.len() {
if nulls.is_valid(row) {
self.hll.add_hashed(self.hashes[row]);
}
}
}
}
Ok(())
}One vectorized pass over the array produces 64 bits per row. Then folding into the sketch is two instructions:
// hyperloglog.rs:116-125
/// Adds a pre-computed hash value directly to the HyperLogLog.
///
/// The hash should be computed using [`HLL_HASH_STATE`], the same hasher used
/// by [`Self::add`].
#[inline]
pub(crate) fn add_hashed(&mut self, hash: u64) {
let index = (hash & HLL_P_MASK) as usize;
let p = ((hash >> HLL_P) | (1_u64 << HLL_Q)).trailing_zeros() + 1;
self.registers[index] = self.registers[index].max(p as u8);
}Read that as: use 14 bits to pick a register, count the leading zeros of the other 50, and keep the maximum seen for that register. That is the whole algorithm. The register value is “the longest run of leading zeros observed for values landing in this bucket”, which is a proxy for how close the value was to the bucket boundary — a proxy for “is this value probably unique”.
Why hashing is the right move, not a hack:
- Uniformity. A good hash makes the register indices uniform and the leading-zero counts geometric, which is exactly the distribution the estimator assumes. The error bound is only valid under that assumption.
- Collisions are already priced in. If two distinct values collide in all 64 bits, the sketch merges them — the count is off by one, in a scheme whose error is already ±0.8%. A 64-bit collision between two values in a billion-row stream has probability ~2.7e-8; it does not need to be prevented, only to be unlikely.
- The cost is already paid.
create_hashesis the same function hash aggregation uses for its group keys, and it is vectorized over the whole batch. - It works for any type. Strings, decimals, lists, structs, dictionaries — all of them hash to 64 bits through the same path.
The price is that the sketch is no longer a function of the values, only of their hashes. Two different values with the same hash are indistinguishable. That is precisely the approximation being bought.
Two hashers, and why the seed is fixed
DataFusion has two hash functions, and the split is deliberate:
// datafusion/common/src/hash_utils.rs:34-48
/// [`RandomState`] is optimized for speed and suitable for hash tables and
/// bloom filters. [`QualityRandomState`] is optimized for statistical quality
/// and suitable for algorithms such as HyperLogLog. The tradeoff is that the
/// fast variant gives up some statistical quality, while the quality variant
/// is slightly slower.
///
/// See: <https://docs.rs/foldhash/0.2.0/src/foldhash/lib.rs.html#17-21>
pub type RandomState = FixedState;
pub type QualityRandomState = foldhash::quality::FixedState;
/// Fixed quality hash state used by HyperLogLog sketches.
///
/// The seed is part of the HLL wire/storage semantics: serialized sketches only
/// remain mergeable if every producer uses the same hash state.
pub const HLL_RANDOM_STATE: QualityRandomState = QualityRandomState::with_seed(0);Aggregation uses foldhash’s fast variant, because a hash table only needs
good-enough distribution. HyperLogLog uses the quality variant, because the
estimator’s error bound depends on the hash being genuinely uniform. And the
seed is 0, fixed forever, because a serialized sketch is 16 KiB of opaque
bytes: if two nodes hashed differently, their sketches could not be merged.
Estimating without materializing registers
The estimator is the classic one, with the modern bias corrections:
// hyperloglog.rs:206-217
#[inline]
fn count_from_histogram(histogram: &[u32; HLL_Q + 2]) -> usize {
let m = NUM_REGISTERS as f64;
let mut z = m * hll_tau((m - histogram[HLL_Q + 1] as f64) / m);
for i in histogram[1..=HLL_Q].iter().rev() {
z += *i as f64;
z *= 0.5;
}
z += m * hll_sigma(histogram[0] as f64 / m);
(0.5 / 2_f64.ln() * m * m / z).round() as usize
}tau and sigma are Ertl’s corrections for small and large cardinalities.
But note what this function needs: a histogram of register values, i.e. the
16 KiB register array.
For the per-group case, DataFusion avoids materializing that array for small
groups. GroupHll is a sparse/dense hybrid:
// datafusion/functions-aggregate/src/approx_distinct.rs:296-334
/// Maximum number of distinct hashes kept in the sparse representation of a
/// per-group sketch before it is promoted to a dense [`HyperLogLog`].
///
/// A dense sketch always occupies [`NUM_REGISTERS`] (16 KiB) regardless of how
/// many values it has seen. The vast majority of groups in a high-cardinality
/// `GROUP BY` only observe a handful of distinct values, so keeping their state
/// as a small list of hashes saves a huge amount of memory (both while
/// aggregating and when serializing the partial state for the final phase).
const SPARSE_LIMIT: usize = 256;
/// Per-group HyperLogLog state used by [`HllGroupsAccumulator`].
///
/// Starts out as a compact list of the (deduplicated) hashes observed for the
/// group and only switches to a full dense [`HyperLogLog`] once it has seen more
/// than [`SPARSE_LIMIT`] distinct values. Folding the stored hashes into a dense
/// sketch produces exactly the same registers as adding the original values one
/// by one, so the cardinality estimate is identical to the per-group
/// [`Accumulator`] path.
#[derive(Clone, Debug)]
enum GroupHll {
/// Distinct hashes seen so far. May contain duplicates between compactions.
Sparse(Vec<u64>),
Dense(Box<HyperLogLog<u8>>),
}The estimate for a sparse group is computed directly from the hash list, without ever building a register array:
// hyperloglog.rs:167-204
/// Estimate the cardinality of a set of precomputed hashes without
/// materializing a full [`NUM_REGISTERS`]-byte register array.
///
/// This is equivalent to adding every hash to a fresh [`HyperLogLog`] via
/// [`HyperLogLog::add_hashed`] and calling [`HyperLogLog::count`], but only does
/// work proportional to the number of hashes. It is used to cheaply estimate the
/// many small groups produced by a high-cardinality `GROUP BY`, where allocating
/// and scanning a 16 KiB sketch per group would dominate the runtime.
///
/// `hashes` may contain duplicates (duplicate hashes are idempotent).
pub(crate) fn count_from_hashes(hashes: &[u64]) -> usize {
...
// For each touched register index keep the maximum rho. Sorting by
// (index, rho) groups equal indices together with the max rho last.
let mut idx_rho: Vec<(usize, u8)> =
hashes.iter().map(|&hash| register_for_hash(hash)).collect();
idx_rho.sort_unstable();
let mut histogram = [0u32; HLL_Q + 2];
let mut touched = 0u32;
...
// All remaining registers are still zero.
histogram[0] = NUM_REGISTERS as u32 - touched;
count_from_histogram(&histogram)
}Sorting the (index, rho) pairs and taking the max rho per index gives the
same histogram as a full register array would — in O(k log k) time and
O(k) memory instead of 16 KiB. For the typical group in a high-cardinality
GROUP BY (a handful of distinct values), this is the difference between 16
KiB and 16 bytes.
The promotion rule is also careful: the sparse list is compacted (sorted and deduped) at twice the limit, and only promoted to dense if it still exceeds the limit:
// approx_distinct.rs:357-377
/// Deduplicate the sparse hash list and, if it still exceeds
/// [`SPARSE_LIMIT`] distinct values, promote it to a dense sketch.
#[cold]
fn compact_or_promote(&mut self, cap_before: usize) -> isize {
let GroupHll::Sparse(v) = self else {
return 0;
};
v.sort_unstable();
v.dedup();
if v.len() > SPARSE_LIMIT {
// cap_before is the capacity already reflected in allocated_bytes.
// Any reallocation caused by the triggering push was never counted and
// is also freed here, so the two cancel out.
*self = GroupHll::Dense(Box::new(fold_sparse_to_hll(v)));
(NUM_REGISTERS as isize) - ((cap_before * size_of::<u64>()) as isize)
} else {The memory accounting is delta-based: add_hash returns the change in
heap bytes so the accumulator can track its footprint incrementally, and the
dense promotion’s 16 KiB is charged at the moment it happens.
The grouped path
For SELECT k, approx_distinct(v) FROM t GROUP BY k there is a dedicated
groups accumulator, one sketch per group:
// approx_distinct.rs:460-490
/// A [`GroupsAccumulator`] for `approx_distinct` that keeps one adaptive
/// (sparse → dense) HyperLogLog sketch per group.
///
/// This is dramatically faster than the generic `GroupsAccumulatorAdapter`
/// fallback for high-cardinality `GROUP BY`s: it processes the whole input in a
/// single vectorized pass (no per-group `take`/slice and no dynamic dispatch),
/// and the sparse representation avoids allocating a 16 KiB sketch for every
/// group when most groups only see a few distinct values.
///
/// # Example
///
/// For `SELECT k, approx_distinct(v) FROM t GROUP BY k`, each group owns one
/// independent sketch:
///
/// ```text
/// group state
/// a Sparse([h1, h2, h3, h2])
/// b Dense(HLL registers)
/// ...
/// ```
Its update loop pre-combines the filter and the value nulls into one mask, so the inner loop visits only the rows that should affect the sketch:
// approx_distinct.rs:524-558
fn update_batch(
&mut self,
values: &[ArrayRef],
group_indices: &[usize],
opt_filter: Option<&BooleanArray>,
total_num_groups: usize,
) -> Result<()> {
self.ensure_groups(total_num_groups);
let array = values[0].as_ref();
self.hashes.clear();
self.hashes.resize(array.len(), 0);
create_hashes([array], &HLL_HASH_STATE, &mut self.hashes)?;
let mut delta: isize = 0;
// Pre-combine value-nulls and filter into one mask so the update loop
// only visits rows that should affect the sketch.
let filter_nulls = opt_filter.map(filter_to_nulls);
let value_nulls = array.logical_nulls();
let combined_nulls =
NullBuffer::union(filter_nulls.as_ref(), value_nulls.as_ref());
match combined_nulls {
None => {
for (row, &hash) in self.hashes.iter().enumerate() {
delta += self.groups[group_indices[row]].add_hash(hash);
}
}
Some(nulls) => {
for row in nulls.valid_indices() {
delta += self.groups[group_indices[row]].add_hash(self.hashes[row]);
}
}
}
self.apply_delta(delta);
Ok(())
}Note that the whole batch is hashed in one vectorized pass, and the group
index array is used to scatter the hashes into per-group sketches. There is
no per-group take and no dynamic dispatch per row — which is exactly the
overhead the generic GroupsAccumulatorAdapter fallback would pay.
Exact when exact is cheap
The most interesting decision in approx_distinct is that DataFusion does
not use a HyperLogLog when the value domain is small enough to count
exactly. For booleans and 8/16-bit integers, an exact bitmap is smaller and
more accurate than a sketch:
// approx_distinct.rs:689-711
#[cold]
fn get_fixed_domain_approx_accumulator(
data_type: &DataType,
) -> Result<Box<dyn Accumulator>> {
match data_type {
DataType::Boolean => Ok(Box::new(ApproxDistinctBitmapWrapper {
inner: BooleanDistinctCountAccumulator::new(),
})),
DataType::UInt8 => Ok(Box::new(ApproxDistinctBitmapWrapper {
inner: BoolArray256DistinctCountAccumulator::new(),
})),
DataType::Int8 => Ok(Box::new(ApproxDistinctBitmapWrapper {
inner: BoolArray256DistinctCountAccumulatorI8::new(),
})),
DataType::UInt16 => Ok(Box::new(ApproxDistinctBitmapWrapper {
inner: Bitmap65536DistinctCountAccumulator::new(),
})),
DataType::Int16 => Ok(Box::new(ApproxDistinctBitmapWrapper {
inner: Bitmap65536DistinctCountAccumulatorI16::new(),
})),A boolean needs 2 bits, a u8 needs 256 bits (32 bytes), a u16 needs 65536
bits (8 KiB). Those are exact, and the ApproxDistinctBitmapWrapper exists
only to convert the Int64 result into the UInt64 that approx_distinct
declares as its return type. The same reasoning drives the exact
count(DISTINCT x), which uses a HashSet for wide integers
(datafusion/functions-aggregate-common/src/aggregate/count_distinct/native.rs:46-53).
Merging sketches
HyperLogLogs merge, which is what makes them usable in a distributed plan:
// hyperloglog.rs:139-150
/// Merge the other [`HyperLogLog`] into this one
pub fn merge(&mut self, other: &HyperLogLog<T>) {
assert!(
self.registers.len() == other.registers.len(),
"unexpected got unequal register size, expect {}, got {}",
self.registers.len(),
other.registers.len()
);
for i in 0..self.registers.len() {
self.registers[i] = self.registers[i].max(other.registers[i]);
}
}Register-wise maximum. That is the entire merge operation, and it is exactly
why the sketch is 16 KiB of u8 rather than a compressed bitmap: the merge is
a max over bytes, vectorizable and branch-free.
Sparse states merge too, by folding the stored hashes back in:
// approx_distinct.rs:174-184
match SerializedHll::decode(v)? {
SerializedHll::Dense(registers) => {
hll.merge(&HyperLogLog::new_with_registers(*registers));
}
SerializedHll::Sparse(chunks) => {
for chunk in chunks {
hll.add_hashed(u64::from_le_bytes(*chunk));
}
}
}The wire format is self-describing: exactly NUM_REGISTERS bytes means a
dense sketch; any multiple of 8 bytes means a sparse list of hashes.
One subtlety: the hash must be of the logical value
Because the sketch is keyed by hash, the hash must be a function of the
value, not of its physical representation. Arrow’s Utf8View type makes this
non-trivial: a short string can live inline in the view buffer, while a long
one lives in a separate data buffer. If the two layouts hashed differently,
the same string would count as two distinct values.
DataFusion has a dedicated test for exactly this:
// approx_distinct.rs:1336-1361
/// Regression: a short (≤ 12-byte) Utf8View string must hash identically
/// in an all-inline batch and in a mixed batch that also contains a long
/// string (which forces a data buffer).
#[test]
fn utf8view_groups_short_string_hashed_consistently_across_batches() {and the underlying hasher has an explicit fast path for the all-inline case that hashes the view rather than the bytes:
// datafusion/common/src/hash_utils.rs:463-490
fn hash_generic_byte_view_array<T: ByteViewType>(
array: &GenericByteViewArray<T>,
random_state: &impl HashState,
hashes_buffer: &mut [u64],
rehash: bool,
) {
// instantiate the correct version based on presence of nulls and external buffers
match (
array.null_count() != 0,
!array.data_buffers().is_empty(),
rehash,
) {
// no nulls or buffers ==> hash the inlined views directly
// don't call the inner function as Rust seems better able to inline this simpler code (2-3% faster)
(false, false, false) => {
for (hash, &view) in hashes_buffer.iter_mut().zip(array.views().iter()) {
*hash = view.hash_one(random_state);
}
}Trade-offs
- The error is not free, and not uniform. ±0.8125% standard error at
P = 14, worst at small cardinalities (wheretauhas to correct a large bias) and at very large ones (where the 64-bit hash space saturates around ~10^19 distinct values). If you need an exact count, theGROUP BYrewrite orcount(DISTINCT x)is right. - The hash must be stable.
HLL_RANDOM_STATEis aconstwith seed 0. If it ever changed, sketches written by an older version would silently stop merging with newer ones — and would count the same value twice across a version boundary. - Collisions merge values. Two distinct values with the same 64-bit hash count as one. At a billion distinct values the collision probability is ~2.7e-8 — negligible, but it is the price of the trick, and it is why the hasher must be the quality variant.
- Not every type is supported.
Float16/32/64are rejected outright byapprox_distinct(“Support for ‘approx_distinct’ for data type Float64 is not implemented”), because hashing a float is ambiguous around0.0/-0.0and NaN payloads. The grouped path excludes them too. - Dense sketches are 16 KiB per group. The sparse/dense hybrid keeps the common case small, but a group that sees more than 256 distinct values pays the full 16 KiB — and the dense state is what gets serialized between the partial and final stages.
- The exact
DISTINCTrewrite has a memory wall of its own. It is semantically perfect but costs one hash table entry per distinct value, per partition. That is the problemapprox_distinctexists to solve, and also why theSPARSE_LIMIThybrid matters: a high-cardinalityGROUP BYwithapprox_distinctper group would otherwise allocate 16 KiB times the number of groups. - The
count(DISTINCT)rewrite moves every other aggregate to a finer grain. The inner group by “has a row per(group, distinct value)pair rather than per group”, which is why the rule checks that it actually buys something before applying it.
The takeaway
Two ideas, one shape. DISTINCT is not an algorithm, it is a GROUP BY with
no aggregates — which means it inherits the entire hash-aggregation
machinery, including the soft-limit early termination. approx_distinct is
not an exact set, it is a fixed-size probabilistic summary whose state is
indexed by a hash rather than by the value.
The hash is the enabling trick, and it is a good one because the cost of hashing has already been paid by the engine that sits next to it. Everything else — the sparse/dense hybrid, the register-wise-max merge, the fixed seed, the fixed-domain exact bitmaps, the Utf8View consistency test — is careful bookkeeping around the fact that once you throw away the value and keep only its hash, the only thing you can still guarantee is a number that is close.