OLAP – Phase 5 Vectorized Expressions and Scan/Filter/Project
The storage layer can persist and read columnar data. Now we need to query it. This phase builds the execution engine’s foundation: expressions that evaluate on entire vectors at once (2048 rows per call), and three physical operators — SeqScan, Filter, and Projection — that form the basic query pipeline.
The key insight is vectorized execution: instead of evaluating amount > 100 one row at a time, we evaluate it on 2048 rows at once in a tight loop. This eliminates per-row function call overhead and lets the CPU pipeline and cache work efficiently.
In DuckDB, these are Expression (src/include/duckdb/planner/expression.hpp), PhysicalTableScan (src/execution/operator/scan/physical_table_scan.cpp), PhysicalFilter, and PhysicalProjection.
Here is the roadmap for the phases to come:
- Phase 5: Vectorized expressions and scan/filter/project
- 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.
Physical Operator Interface
Every operator in the execution pipeline implements a simple Volcano-style iterator:
1
2
3
4
5
type PhysicalOperator interface {
Init()
Next() *vector.DataChunk
Close()
}
Operators form a tree: a parent pulls data from its children by calling Next(). When Next() returns nil, the input is exhausted.
Expressions
An Expression takes a DataChunk (the current batch of rows) and returns a Vector (one result per row):
1
2
3
4
type Expression interface {
Execute(chunk *vector.DataChunk) *vector.Vector
ResultType() vector.LogicalType
}
ColumnRefExpr
References a column by index — just returns the existing vector from the chunk:
1
2
3
4
5
6
7
8
type ColumnRefExpr struct {
ColumnIndex int
ReturnType vector.LogicalType
}
func (e *ColumnRefExpr) Execute(chunk *vector.DataChunk) *vector.Vector {
return chunk.Column(e.ColumnIndex)
}
ConstantExpr
Returns a constant vector — a single value broadcast to all rows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type ConstantExpr struct {
Value any
Typ vector.LogicalType
}
func (e *ConstantExpr) Execute(chunk *vector.DataChunk) *vector.Vector {
v := vector.NewConstantVector(e.Typ)
if e.Value == nil {
v.SetNullValue(0)
} else {
v.SetValue(0, e.Value)
}
v.SetCount(chunk.Count())
return v
}
ComparisonExpr
Compares two sub-expressions element-wise, producing a boolean vector:
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
type ComparisonExpr struct {
Left Expression
Right Expression
Op ComparisonType // Equal, NotEqual, LessThan, etc.
}
func (e *ComparisonExpr) Execute(chunk *vector.DataChunk) *vector.Vector {
left := e.Left.Execute(chunk)
right := e.Right.Execute(chunk)
count := chunk.Count()
result := vector.NewFlatVector(vector.Boolean)
result.SetCount(count)
bools := result.BoolData()
for i := 0; i < count; i++ {
li := vecIdx(left, i)
ri := vecIdx(right, i)
if !left.IsValid(li) || !right.IsValid(ri) {
result.SetNull(i) // NULL propagation
continue
}
bools[i] = compareAtIndex(left, li, right, ri, e.Op)
}
return result
}
The vecIdx helper returns 0 for constant vectors (always read the single value) or i for flat vectors:
1
2
3
4
5
6
func vecIdx(v *vector.Vector, i int) int {
if v.IsConstant() {
return 0
}
return i
}
ConjunctionExpr (AND / OR)
Combines boolean vectors with SQL three-valued logic. The key subtlety: NULL AND false = false (not NULL), because false short-circuits regardless:
1
2
3
4
5
6
7
8
9
10
case ConjAnd:
if aValid && !aBools[ai] {
bools[i] = false // false AND anything = false
} else if bValid && !bBools[bi] {
bools[i] = false // anything AND false = false
} else if aValid && bValid {
bools[i] = aBools[ai] && bBools[bi]
} else {
result.SetNull(i) // NULL AND true = NULL
}
ArithmeticExpr
Performs arithmetic (+, -, *, /) with type promotion and null propagation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (e *ArithmeticExpr) Execute(chunk *vector.DataChunk) *vector.Vector {
left := e.Left.Execute(chunk)
right := e.Right.Execute(chunk)
for i := 0; i < count; i++ {
if !left.IsValid(li) || !right.IsValid(ri) {
result.SetNull(i)
continue
}
lf := toFloat64(left, li)
rf := toFloat64(right, ri)
if e.Op == ArithDiv && rf == 0 {
result.SetNull(i) // division by zero → NULL
continue
}
// compute and store result
}
}
Type promotion follows the rule: if either operand is Float64, the result is Float64; otherwise if either is Int64, the result is Int64; otherwise Int32.
Sequential Scan with Zone Map Pruning
The scan operator reads DataChunks from a table’s row groups, optionally using zone maps to skip row groups that can’t contain matches:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type SeqScanOperator struct {
table *catalog.Table
columnIndices []int
predicate *ZoneMapPredicate
rgIdx int
}
func (s *SeqScanOperator) Next() *vector.DataChunk {
for s.rgIdx < len(s.table.RowGroups) {
rg := s.table.RowGroups[s.rgIdx]
s.rgIdx++
if s.predicate != nil && s.canSkip(rg) {
continue // zone map says no match possible
}
chunk := rg.ToDataChunk(s.columnIndices)
if chunk.Count() > 0 {
return chunk
}
}
return nil
}
Zone map pruning is a coarse-grained filter — it eliminates entire row groups (122,880 rows) before any decompression happens. The fine-grained filter (row-level) is handled by the FilterOperator.
Filter Operator
The filter evaluates a predicate expression, converts the boolean result to a SelectionVector, and compacts the chunk to only include matching rows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (f *FilterOperator) Next() *vector.DataChunk {
for {
chunk := f.child.Next()
if chunk == nil {
return nil
}
sel := f.executor.Select(f.predicate, chunk)
if sel.Count() == 0 {
continue // no matches, try next chunk
}
if sel.Count() == chunk.Count() {
return chunk // all match, pass through
}
return compactChunk(chunk, sel) // partial match
}
}
compactChunk copies only the matching rows (identified by the SelectionVector) into a new DataChunk:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func compactChunk(chunk *vector.DataChunk, sel *vector.SelectionVector) *vector.DataChunk {
types := chunk.Types()
result := vector.NewDataChunk(types)
for col := 0; col < chunk.ColumnCount(); col++ {
src := chunk.Column(col)
dst := result.Column(col)
for _, idx := range sel.Indices() {
if !src.IsValid(int(idx)) {
dst.AppendNull()
} else {
appendTyped(dst, src, int(idx))
}
}
}
result.SetCount(sel.Count())
return result
}
Projection Operator
The projection evaluates a list of output expressions and assembles the results into a new DataChunk:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type ProjectionOperator struct {
child PhysicalOperator
expressions []Expression
}
func (p *ProjectionOperator) Next() *vector.DataChunk {
chunk := p.child.Next()
if chunk == nil {
return nil
}
types := make([]vector.LogicalType, len(p.expressions))
for i, expr := range p.expressions {
types[i] = expr.ResultType()
}
result := vector.NewDataChunk(types)
for i, expr := range p.expressions {
v := expr.Execute(chunk)
result.SetColumn(i, v)
}
result.SetCount(chunk.Count())
return result
}
The Pipeline
A query like SELECT region, amount * 1.1 FROM orders WHERE amount > 100 becomes:
1
2
3
ProjectionOperator [region, amount * 1.1]
└── FilterOperator [amount > 100]
└── SeqScanOperator [orders, columns: region, amount]
Data flows bottom-up: SeqScan yields DataChunks → Filter keeps only rows where amount > 100 → Projection computes the output columns.
Summary
The execution engine processes data in 2048-row batches through a pipeline of operators. Expressions evaluate on entire vectors at once, with null propagation and type promotion. Zone map pruning at the scan level eliminates row groups before decompression, and SelectionVector-based filtering at the operator level avoids unnecessary data copying.