OLAP – Phase 3 Compression

Columnar storage has a property that row-based storage doesn’t: values in a column tend to be similar. A status column might have only 5 distinct values across millions of rows. A timestamp column is monotonically increasing. A year column spans a narrow range. Compression codecs exploit these patterns to shrink data 5-10x, and because columns are stored independently, each column gets the codec that fits its data best.

This phase implements four compression codecs — RLE, Dictionary, Bitpacking, and Delta — with automatic selection that tries all applicable codecs and picks the smallest.

In DuckDB, these correspond to compression functions in src/storage/compression/rle.cpp, dictionary_compression.cpp, bitpacking.cpp, and patas.cpp (DuckDB uses PATAS for floating-point; we use delta for integers).

Here is the roadmap for the phases to come:

  • Phase 3: Compression
  • Phase 4: Table, catalog, and bulk loading
  • 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.

Codec Interface

All compression codecs implement a simple interface:

1
2
3
4
5
type Codec interface {
    Type() CompressionType
    Compress(data []byte, count int, typ vector.LogicalType) ([]byte, error)
    Decompress(data []byte, count int, typ vector.LogicalType) ([]byte, error)
}

The input/output is raw bytes — the column segment serializes its typed values into bytes before compression, and deserializes after decompression. This keeps the codecs type-aware but data-agnostic.

Auto-Selection

Rather than choosing a codec per-table or per-column, we try all applicable codecs for each segment and pick the one that produces the smallest output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func AutoSelectCompression(data []byte, count int, typ vector.LogicalType) (CompressionType, []byte) {
    bestType := CompressionNone
    bestData := data

    for ct, codec := range codecs {
        if !codecApplies(ct, typ) {
            continue
        }
        compressed, err := codec.Compress(data, count, typ)
        if err != nil {
            continue
        }
        if len(compressed) < len(bestData) {
            bestType = codec.Type()
            bestData = compressed
        }
    }
    return bestType, bestData
}

Not every codec applies to every type:

Codec Bool Int32 Int64 Float64 Varchar
RLE yes yes yes yes yes
Dictionary yes yes yes yes yes
Bitpacking - yes yes - -
Delta - yes yes - -

RLE (Run-Length Encoding)

RLE replaces consecutive repeated values with (value, count) pairs. It’s best for sorted or low-cardinality columns where the same value appears many times in a row.

Example: [1, 1, 1, 2, 2, 3, 3, 3, 3] becomes [(1,3), (2,2), (3,4)]

For fixed-width types, the format is:

1
[numRuns: 4 bytes] [value: N bytes, runLength: 4 bytes] ...
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
func rleCompressFixed(data []byte, count, typeSize int) ([]byte, error) {
    var buf bytes.Buffer
    buf.Write(make([]byte, 4)) // placeholder for numRuns

    numRuns := uint32(0)
    i := 0
    for i < count {
        valStart := i * typeSize
        val := data[valStart : valStart+typeSize]
        runLen := uint32(1)
        for j := i + 1; j < count; j++ {
            nextStart := j * typeSize
            if bytes.Equal(val, data[nextStart:nextStart+typeSize]) {
                runLen++
            } else {
                break
            }
        }
        buf.Write(val)
        b := make([]byte, 4)
        binary.LittleEndian.PutUint32(b, runLen)
        buf.Write(b)
        numRuns++
        i += int(runLen)
    }
    // write numRuns at the start
    out := buf.Bytes()
    binary.LittleEndian.PutUint32(out[0:4], numRuns)
    return out, nil
}

When it wins: a sorted column with many consecutive duplicates. A status column sorted as ["active","active",...,"inactive","inactive",...] compresses to just a few runs.

When it loses: columns with high cardinality or random order — each value is its own run, and we pay 4 bytes of run-length overhead per value.


Dictionary Encoding

Dictionary encoding builds a table of unique values, then replaces each value with its integer code. It’s best for low-cardinality columns regardless of sort order.

Example: ["US","UK","US","FR","US","UK"] becomes dictionary {US→0, UK→1, FR→2} plus codes [0, 1, 0, 2, 0, 1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func dictCompressFixed(data []byte, count, typeSize int) ([]byte, error) {
    dict := make(map[string]uint32)
    var dictOrder [][]byte
    codes := make([]uint32, count)

    for i := 0; i < count; i++ {
        val := string(data[i*typeSize : (i+1)*typeSize])
        code, exists := dict[val]
        if !exists {
            code = uint32(len(dict))
            dict[val] = code
            dictOrder = append(dictOrder, []byte(val))
        }
        codes[i] = code
    }

    var buf bytes.Buffer
    // write dict size, then dict entries, then codes
    // ...
    return buf.Bytes(), nil
}

When it wins: a column with 5 distinct country codes across 100,000 rows. The dictionary is 5 entries (tiny), and each row stores a 4-byte code instead of a variable-length string.

When it loses: columns where every value is unique (like a UUID column) — the dictionary is as large as the original data, plus we still store the codes.


Bitpacking (Frame-of-Reference)

Bitpacking subtracts the minimum value from all values, then stores only the bits needed to represent the range. It’s best for integer columns with a small value range.

Example: values [1000, 1001, 1002, 1003] have range 3. ceil(log2(3)) = 2 bits per value instead of 32.

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
33
func bitpackCompressInt32(data []byte, count int) ([]byte, error) {
    values := make([]int32, count)
    for i := 0; i < count; i++ {
        values[i] = int32(binary.LittleEndian.Uint32(data[i*4 : i*4+4]))
    }

    minVal := values[0]
    maxVal := values[0]
    for _, v := range values[1:] {
        if v < minVal { minVal = v }
        if v > maxVal { maxVal = v }
    }

    rangeVal := uint32(maxVal - minVal)
    bitWidth := bits.Len32(rangeVal)
    if bitWidth == 0 {
        bitWidth = 1
    }

    packedSize := (count*bitWidth + 7) / 8
    out := make([]byte, 1+4+packedSize) // bitWidth(1) + baseVal(4) + packed
    out[0] = byte(bitWidth)
    binary.LittleEndian.PutUint32(out[1:5], uint32(minVal))

    packed := out[5:]
    bitOffset := 0
    for _, v := range values {
        offset := uint32(v - minVal)
        packBits32(packed, bitOffset, offset, bitWidth)
        bitOffset += bitWidth
    }
    return out, nil
}

Savings example: 122,880 Int32 values in range [1000, 1015]. Range = 15, so bitWidth = 4 bits per value.

  • Uncompressed: 122,880 × 4 = 491,520 bytes
  • Bitpacked: 122,880 × 4 bits / 8 = 61,440 bytes + 5 bytes header = 8x reduction

Delta Encoding

Delta encoding stores the first value, then the difference between consecutive values as variable-length integers (varints). It’s best for monotonically increasing or slowly changing sequences like timestamps or auto-increment IDs.

Example: [100, 102, 105, 106] becomes first=100, deltas=[+2, +3, +1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func deltaCompressInt32(data []byte, count int) ([]byte, error) {
    if count == 0 {
        return nil, nil
    }
    out := make([]byte, 4, 4+count*binary.MaxVarintLen64)
    first := int32(binary.LittleEndian.Uint32(data[0:4]))
    binary.LittleEndian.PutUint32(out[0:4], uint32(first))

    prev := first
    varintBuf := make([]byte, binary.MaxVarintLen64)
    for i := 1; i < count; i++ {
        cur := int32(binary.LittleEndian.Uint32(data[i*4 : i*4+4]))
        delta := int64(cur) - int64(prev)
        n := binary.PutVarint(varintBuf, delta)
        out = append(out, varintBuf[:n]...)
        prev = cur
    }
    return out, nil
}

When it wins: a timestamp column with 1-second increments. Each delta is 1, which encodes as a single byte. 122,880 Int64 values: 983KB uncompressed → ~123KB delta-encoded.

When it loses: random integers with large, unpredictable gaps — deltas need as many bits as the originals, plus varint overhead.


How It All Fits Together

When a Vector is serialized into a ColumnSegment (Phase 2), the raw bytes pass through AutoSelectCompression:

  1. Serialize the vector’s typed values into raw bytes
  2. Try RLE, Dictionary, Bitpacking, and Delta (where applicable)
  3. Pick whichever produces the smallest output
  4. Store the compression type in the segment header

On read, the segment header’s Compression field tells the reader which codec to use for decompression before converting bytes back to a Vector.

The compression type is stored per-segment (not per-table or per-column), so the same column might use bitpacking in one row group and dictionary in another — whatever fits that particular chunk of data best.