OLAP – Phase 6 Hash Aggregation

The scan/filter/project pipeline from Phase 5 processes rows one batch at a time — it’s a streaming operation where each batch flows through independently. Aggregation (GROUP BY with SUM, COUNT, AVG) is fundamentally different: it needs to see all the data before producing any output. This makes it a pipeline breaker — it consumes all input, builds an internal state, then emits results.

This phase implements GROUP BY aggregation using a hash table: the HashAggregateOperator, the GroupByHashTable, and five aggregate functions (COUNT, SUM, AVG, MIN, MAX).

In DuckDB, these are PhysicalHashAggregate (src/execution/operator/aggregate/physical_hash_aggregate.cpp) and GroupedAggregateHashTable (src/execution/aggregate_hashtable.cpp).

Here is the roadmap for the phases to come:

  • Phase 6: Hash aggregation
  • Phase 7: Hash join
  • Phase 8: SQL parser
  • Phase 9: Query planner and optimizer
  • Phase 10: Sorting, parallel execution, REPL, and server

Full Source Code

The code referenced in this post can be found in https://gitlab.com/kimserey.lam/olap-learn.

Aggregate Functions

Each aggregate function follows a three-phase lifecycle — Initialize, Update (called once per input row), and Finalize (called once to produce the result):

1
2
3
4
5
6
7
8
9
type AggregateState interface {
    Update(value any, isNull bool)
    Finalize() (any, bool)
}

type AggregateFunction interface {
    NewState() AggregateState
    ResultType() vector.LogicalType
}

COUNT(*)

Counts all rows, including NULLs:

1
2
3
4
type countStarState struct{ count int64 }

func (s *countStarState) Update(_ any, _ bool) { s.count++ }
func (s *countStarState) Finalize() (any, bool) { return s.count, true }

COUNT(column)

Counts only non-null values:

1
2
3
4
5
6
7
type countState struct{ count int64 }

func (s *countState) Update(_ any, isNull bool) {
    if !isNull {
        s.count++
    }
}

SUM

Accumulates numeric values, returning NULL if no non-null values were seen:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type sumState struct {
    sum      float64
    hasValue bool
}

func (s *sumState) Update(value any, isNull bool) {
    if isNull {
        return
    }
    s.sum += toFloat(value)
    s.hasValue = true
}

func (s *sumState) Finalize() (any, bool) {
    if !s.hasValue {
        return nil, false
    }
    return s.sum, true
}

AVG

Tracks both sum and count, dividing at finalization:

1
2
3
4
5
6
7
8
9
10
11
type avgState struct {
    sum   float64
    count int64
}

func (s *avgState) Finalize() (any, bool) {
    if s.count == 0 {
        return nil, false
    }
    return s.sum / float64(s.count), true
}

MIN / MAX

Track the running minimum or maximum:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type minMaxState struct {
    val      any
    hasValue bool
    isMin    bool
}

func (s *minMaxState) Update(value any, isNull bool) {
    if isNull {
        return
    }
    if !s.hasValue {
        s.val = value
        s.hasValue = true
        return
    }
    if s.isMin {
        if anyLess(value, s.val) {
            s.val = value
        }
    } else {
        if anyLess(s.val, value) {
            s.val = value
        }
    }
}

GroupByHashTable

The hash table maps group keys to aggregate states using open addressing with linear probing:

1
2
3
4
5
6
7
8
9
10
11
12
13
type GroupByHashTable struct {
    entries  []htEntry
    capacity int
    count    int
    funcs    []AggregateFunction
}

type htEntry struct {
    occupied bool
    hash     uint64
    keys     []any
    states   []AggregateState
}

FindOrCreate

For each row, FindOrCreate looks up the group keys. If the group exists, it returns the existing aggregate states. If not, it creates a new entry with fresh states:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (ht *GroupByHashTable) FindOrCreate(keys []any) []AggregateState {
    h := hashKeys(keys)
    idx := int(h % uint64(ht.capacity))

    for {
        e := &ht.entries[idx]
        if !e.occupied {
            // new group
            e.occupied = true
            e.hash = h
            e.keys = copyKeys(keys)
            e.states = make([]AggregateState, len(ht.funcs))
            for i, f := range ht.funcs {
                e.states[i] = f.NewState()
            }
            ht.count++
            if ht.count*2 > ht.capacity {
                ht.grow()
            }
            return e.states
        }
        if e.hash == h && keysEqual(e.keys, keys) {
            return e.states // existing group
        }
        idx = (idx + 1) % ht.capacity // linear probing
    }
}

The cached hash value (e.hash) avoids full key comparison on ~99% of probes — a hash mismatch means the keys can’t be equal.


HashAggregateOperator

The operator has two phases:

  1. Sink phase: consume all input from the child operator, update hash table entries
  2. Source phase: scan the hash table, emit result DataChunks
1
2
3
4
5
6
7
8
func (op *HashAggregateOperator) Next() *vector.DataChunk {
    if !op.done {
        op.sink()          // consume all input
        op.done = true
        op.collectResults()
    }
    return op.emitNextChunk()
}

Processing Input Chunks

For each input chunk, evaluate the group-by expressions and aggregate input expressions, then update the hash table:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (op *HashAggregateOperator) processChunk(chunk *vector.DataChunk) {
    // evaluate group-by expressions → groupVecs
    // evaluate aggregate inputs → aggVecs

    for row := 0; row < count; row++ {
        // extract keys for this row
        for k, gv := range groupVecs {
            gi := vecIdx(gv, row)
            if !gv.IsValid(gi) {
                keys[k] = nil
            } else {
                keys[k] = readValue(gv, gi)
            }
        }

        states := op.ht.FindOrCreate(keys)

        // update each aggregate with this row's value
        for a, av := range aggVecs {
            if av == nil {
                states[a].Update(nil, false) // COUNT(*)
            } else {
                ai := vecIdx(av, row)
                if !av.IsValid(ai) {
                    states[a].Update(nil, true)
                } else {
                    states[a].Update(readValue(av, ai), false)
                }
            }
        }
    }
}

Ungrouped Aggregation

A query like SELECT SUM(amount) FROM orders (no GROUP BY) has one implicit group containing all rows. If no rows match the filter, the hash table is empty, so we create a single entry with fresh (unfed) states — this correctly returns SUM = NULL:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (op *HashAggregateOperator) collectResults() {
    if len(op.groupByExprs) == 0 && op.ht.Count() == 0 {
        states := make([]AggregateState, len(op.aggregates))
        for i, agg := range op.aggregates {
            states[i] = agg.Function.NewState()
        }
        op.results = append(op.results, htResult{keys: nil, states: states})
        return
    }
    op.ht.Scan(func(keys []any, states []AggregateState) {
        op.results = append(op.results, htResult{keys: keys, states: states})
    })
}

Example

1
SELECT region, SUM(amount), COUNT(*) FROM orders GROUP BY region;

Input:

1
2
3
4
5
6
region | amount
"west" | 100
"east" | 200
"west" | 150
"east" | 300
"west" | 50

After processing all rows, the hash table contains:

Group Key SUM state COUNT state
“west” sum=300 count=3
“east” sum=500 count=2

Output after finalization:

1
2
3
region | SUM(amount) | COUNT(*)
"west" | 300.00      | 3
"east" | 500.00      | 2

Summary

Hash aggregation is the first pipeline breaker — it must see all input before producing any output. The hash table maps group keys to aggregate states using open addressing with linear probing. Each aggregate function follows an Initialize/Update/Finalize lifecycle that handles NULL propagation correctly. The operator is the bridge between the streaming scan/filter pipeline and the next phase: hash join.