Columnar Storage: Difference between revisions

From MemCP
Jump to navigation Jump to search
(Created page with "The advantages for columnar storages over row based storages are the ability for good in-memory compression '''(low memory usage)''' and cache locality when accessing only few columns '''(performance)'''. == How we designed the Interface == When designing an interface for a storage engine for an in-memory database, a lot of considerations have to be made. Here are the design goals: * The interface must be simple so that using it does not require implementing all kinds...")
 
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
Line 1: Line 1:
The advantages for columnar storages over row based storages are the ability for good in-memory compression '''(low memory usage)''' and cache locality when accessing only few columns '''(performance)'''.
<!-- Copyright (C) 2026 Carl-Philip Haensch -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
= Columnar Storage =


== How we designed the Interface ==
Column-oriented storage keeps the values of each column together instead of storing complete rows next to one another. For MemCP this serves two related goals:
When designing an interface for a storage engine for an in-memory database, a lot of considerations have to be made. Here are the design goals:


* The interface must be simple so that using it does not require implementing all kinds of corner cases (especially with datatypes)
* '''Compression:''' values of one column usually have similar types, ranges, repetition, or ordering and can use a specialized compact encoding.
* The interface must allow fast value fetches for random data access
* '''Cache locality:''' a query reads only referenced columns, so scans avoid moving unrelated row fields through memory and CPU caches.
* The interface must allow analyzing the data and finding the perfect storage and compression format


Here’s what we came up with:
Compression is not only a capacity feature. A lightweight decoder can be cheaper than fetching additional cache lines from a larger uncompressed representation.
type ColumnStorage interface {
        GetValue(uint) any // read function
        // buildup functions 1) prepare 2) scan, 3) proposeCompression(), if != nil repeat at 1, 4) init, 5) build; all values are passed through twice
        // analyze
        prepare()
        scan(uint, any)
        proposeCompression() ColumnStorage
        // store
        init(uint)
        build(uint, any)
        finish()
        // serialization
        Serialize(io.Writer)
        Deserialize(io.Reader)
}
The read interface is pretty simple: <code>GetValue(i)</code> will read the column value at recordId <code>i</code> and return the <code>scmer</code> value. <code>scmer</code> is a kind of „any“ datatype that is used in our scheme interpreter to further process the data.


== Data Analysis on Columnar Storages ==
== Main and delta storage ==
The write interface is a bit more complicated and basically works in two passes:


# analyze phase:
Each shard separates stable main rows from recent changes. Main storage is columnar and compressed. Inserts, updates, and deletions are represented through delta structures and transaction visibility metadata until rebuild work produces a new main generation.
#* <code>prepare()</code> is called
#* every future value that shall be stored will be passed to <code>scan(recordId, value)</code>
#* the columnar storage will analyze the data. If it knows a better storage format than its own class, it will return an other column container when calling <code>proposeCompression()</code>
#* when <code>proposeCompression()</code> returns another container, repeat step 1 analyze phase with the new container
#* otherwise proceed with the store phase
# store phase
#* <code>init(size)</code> is called where the storage can allocate the memory for its columns
#* for each value, <code>build(recordId, value)</code> is called to write the data to memory
#* for cleanup of possible reverse dictionaries that are only needed in the store phase, <code>finish()</code> is called


During analyze phase, statistics about the data can be collected. This allows a storage engine to check how wide the integers are or if there are lots of string duplicates.
Scans combine main and delta data without exposing that physical split to SQL. Ordered indexes merge their compressed main-record permutation with index-local delta entries; deleted or invisible records remain filtered according to the active transaction.


The classes implementing that interface are then structured as follows:
This design keeps immutable compressed columns efficient while allowing writes without rebuilding the complete column for every statement. See [[Shards, RecordIDs, Main Storage, Delta Storage]].


* <code>StorageSCMER</code> is a universal value storage; fast but needs lot of RAM
== Interface design ==
* <code>StorageSCMER</code> analyzes a column whether it better fits the StorageString or StorageInt scheme; otherwise (i.e. for float64 values, stay at StorageSCMER)
* <code>StorageInt</code> is a bit compressed integer storage. When analyzed values do not exceed i.e. 15, the storage will only eat up 4 bits per stored integer. The numbers are stored in an array of 64 bit integers which are shifted when reading and writing
* <code>StorageString</code> is a dictionary compressed string storage using golang slices


With this interface we have built a powerful interface to implement any columnar storage compression format. We can support bit compressed integers as well as dictionaries.
The storage interface has to support several competing requirements:


== Evaluation ==
* concurrent random reads for point lookups;
We loaded a bunch of 55MiB of .jsonl files into our storage. This is our memory usage using the storage interface:
* efficient sequential and arbitrary-ID batch reads;
* format-specific cached decoding without sharing mutable reader state between goroutines;
* analysis of incoming values before selecting a representation;
* statistics for query planning;
* stable persistence across software upgrades;
* optional native JIT emission for hot value access.
 
The original implementation can be understood through this deliberately reduced two-pass model. It is not the complete current Go interface, but it exposes the stable idea more clearly than the optimized implementation:
 
<syntaxhighlight lang="go">type ColumnStorage interface {
    GetValue(recordID uint32) any
 
    // analysis pass
    prepare()
    scan(recordID uint32, value any)
    proposeCompression() ColumnStorage
 
    // build pass
    init(size uint32)
    build(recordID uint32, value any)
    finish()
 
    Serialize(io.Writer)
    Deserialize(io.Reader)
}</syntaxhighlight>
 
The optimized code adds batch readers, transaction-aware access, statistics, versioned serialization and JIT hooks around this model; it does not change the analyze → choose representation → build sequence.
 
The current read side is split into <code>ColumnStorage</code> and a per-goroutine <code>ColumnReader</code>:
 
<syntaxhighlight lang="go">type ColumnReader interface {
    GetValue(recid uint32) Scmer
    GetValueMulti(recids []uint32, target []Scmer, stride int)
    GetValueRange(recid, count uint32, target []Scmer, stride int)
}</syntaxhighlight>
 
<code>GetValue</code> supports one random RecordID. <code>GetValueRange</code> reads consecutive IDs, allowing bit-position calculations, binary searches, or decoder initialization to be shared by a complete run. <code>GetValueMulti</code> gathers arbitrary IDs such as an index permutation and detects consecutive regions where the encoding benefits from a sequential path.
 
Both batch methods write into a caller-provided target with a configurable stride. A scan assembling an interleaved row buffer can therefore fetch one column at a time without allocating an intermediate list for every value.
 
<code>GetCachedReader()</code> returns a reader optimized for repeated access. Such a reader belongs to one goroutine and must not be shared. Transaction-dependent formats can provide a transaction-bound reader; callers should use shard/table helpers rather than reaching into column maps directly.
 
== Analysis and build phases ==
 
Column creation and shard rebuild use a two-pass process so the format can be selected from the actual data before allocating its final representation.
 
# '''Analyze'''
#* <code>prepare()</code> resets format-specific observations.
#* Each future value is passed to <code>scan(recordId, value)</code>.
#* The implementation records facts such as type, range, scale, distinctness, repetition, NULL/default frequency, run structure, and string dictionary potential.
#* <code>proposeCompression(size)</code> may return a more suitable lossless storage implementation.
#* If the format changes, analysis repeats for the proposed representation.
# '''Build'''
#* <code>init(size)</code> allocates the final structures.
#* <code>build(recordId, value)</code> encodes every value.
#* <code>finish()</code> releases temporary builder state and finalizes dictionaries or indexes needed by the representation.
 
This makes compression a data-dependent decision rather than a fixed mapping from SQL type to one physical format. The selected storage still reports a cheap distinct-count estimate for physical planning.
 
== Storage formats ==
 
The current implementation includes and composes formats such as:


As you see, RAM compressed columnar storage needs only about half the RAM compared to disk based InnoDB storage or .json files.
{| class="wikitable"
{| class="wikitable"
|'''Storage'''
|'''Memory Usage'''
|-
|-
|MySQL InnoDB original storage
! Format
|55 MiB Disk Space
! Purpose
|-
| <code>StorageSCMER</code>
| Generic Scheme values and the universal starting representation when no narrower format is yet known.
|-
| <code>StorageConst</code>
| One repeated value without storing it once per record.
|-
| <code>StorageSparse</code>
| Sparse/default-heavy columns represented by exceptional RecordIDs and values.
|-
| <code>StorageInt</code>
| Bit-packed integers whose width and offset are derived from the observed range.
|-
| <code>StorageSeq</code>
| Arithmetic sequences and runs such as ordered identifiers.
|-
|-
|Export from MySQL in .jsonl format
| <code>StorageDecimal</code>
|55 MiB Disk Space
| Exact fixed-scale decimals backed by an appropriate integer representation.
|-
|-
|
| <code>StorageFloat</code>
|
| IEEE floating-point values.
|-
|-
|Imported .jsonl as []map[string]any into delta storage
| <code>StorageString</code>
|233 MiB RAM
| Dictionary or buffer-based strings with specialized batch decoding.
|-
|-
|Moved to main storage only using <code>StorageSCMER</code>
| <code>StoragePrefix</code>
|81 MiB RAM
| Prefix/suffix decomposition for strings where that representation is applicable; currently experimental for automatic selection.
|-
|-
|Using <code>StorageInt</code> for integer columns
| <code>StorageEnum</code>
|46 MiB RAM
| rANS entropy coding for suitable low-cardinality values.
|-
|-
|Using <code>StorageString</code> for string columns
| <code>OverlayBlob</code>
|58 MiB RAM
| External large binary/blob values layered over a base representation.
|-
|-
|Using <code>StorageInt</code> and <code>StorageString</code>
| computed/cache proxy
|23 MiB RAM
| Values derived from other columns or populated on demand while retaining the column-reader contract.
|}
|}
For more info about compression, read the article about [[In-Memory Compression, Columnar Compression Techniques|Compression]]
 
Formats can be nested. For example, a decimal delegates its scaled integer payload to another storage, and string or sequence metadata can itself use compressed integer representations.
 
== Batch execution and late materialization ==
 
MemCP scan operators fetch filter columns first and process RecordIDs in batches. Only surviving IDs need map, join, or output columns. Consecutive survivors use <code>GetValueRange</code>; ordered or index-derived ID lists use <code>GetValueMulti</code>.
 
This is late materialization: complete logical rows are assembled only at an edge that needs them. Filters, projections, aggregation, RecSet operations, and ordered scans can remain column-oriented in the hot path. It also avoids one interface call and one decoder restart per element.
 
For supported hot expressions, a storage can emit native access code through <code>JITEmit</code>. Unsupported JIT shapes retain the normal reader path and must preserve identical values.
 
== Concurrency and locking ==
 
A stable <code>ColumnStorage</code> pointer can be read concurrently, but the shard structures that publish it are protected by the shard lock. Scan and planner code must obtain storages through helpers such as <code>getColumnStorageOrPanic</code>, <code>ColumnReader</code>, or their transaction-aware forms instead of reading the shard's column map directly.
 
Read-only snapshots use the shard read lock; mutation, rebuild, log replay, delta changes, and index publication require the write lock and concurrency rights. Locks and rights must be released through panic-safe cleanup. Per-goroutine cached readers avoid a lock or mutable decoder shared inside the element loop.
 
== Persistence and compatibility ==
 
Every serialized column begins with a permanently assigned magic byte identifying its storage type. Most types then carry a layout-version byte. Binary layout changes must increment that version and retain all older deserializers so existing database files remain readable.
 
Legacy magic bytes <code>1</code>, <code>2</code>, <code>13</code>, and <code>40</code> have no version byte. An incompatible change to one of those layouts requires a new magic byte; the legacy assignment remains a read-only reader forever. Magic values must never be renumbered or reused.
 
Serialization compatibility is therefore part of the user-visible durability contract, not merely an internal implementation detail. See [[Persistency and Performance Guarantees]].
 
== Measuring compression ==
 
An early 55 MiB JSONL experiment produced the following observations. It mixed disk and RAM measurements and did not record schema, cardinalities, process baseline, engine mode, versions, warm-up or repeatability, so it is historical evidence—not a controlled MySQL comparison or a universal ratio.
 
{| class="wikitable"
! Representation !! Reported size
|-
| MySQL InnoDB source || 55 MiB disk
|-
| JSONL export || 55 MiB disk
|-
| Imported maps in delta storage || 233 MiB RAM
|-
| Main storage using only <code>StorageSCMER</code> || 81 MiB RAM
|-
| Integer columns using <code>StorageInt</code> || 46 MiB RAM
|-
| String columns using <code>StorageString</code> || 58 MiB RAM
|-
| Combined integer and string encodings || 23 MiB RAM
|}
 
A useful evaluation should report the source dataset and generator, schema and row count, per-column cardinality/range, selected storage formats, main/delta split, MemCP commit, ENGINE, process baseline, measured column sizes, indexes, and repeatable query timings. Compare equal logical data and distinguish allocated RAM, resident memory, serialized bytes, and external backend usage. See [[Performance Measurement]] and [[In-Memory Compression, Columnar Compression Techniques]].

Revision as of 11:59, 28 August 2026

Columnar Storage

Column-oriented storage keeps the values of each column together instead of storing complete rows next to one another. For MemCP this serves two related goals:

  • Compression: values of one column usually have similar types, ranges, repetition, or ordering and can use a specialized compact encoding.
  • Cache locality: a query reads only referenced columns, so scans avoid moving unrelated row fields through memory and CPU caches.

Compression is not only a capacity feature. A lightweight decoder can be cheaper than fetching additional cache lines from a larger uncompressed representation.

Main and delta storage

Each shard separates stable main rows from recent changes. Main storage is columnar and compressed. Inserts, updates, and deletions are represented through delta structures and transaction visibility metadata until rebuild work produces a new main generation.

Scans combine main and delta data without exposing that physical split to SQL. Ordered indexes merge their compressed main-record permutation with index-local delta entries; deleted or invisible records remain filtered according to the active transaction.

This design keeps immutable compressed columns efficient while allowing writes without rebuilding the complete column for every statement. See Shards, RecordIDs, Main Storage, Delta Storage.

Interface design

The storage interface has to support several competing requirements:

  • concurrent random reads for point lookups;
  • efficient sequential and arbitrary-ID batch reads;
  • format-specific cached decoding without sharing mutable reader state between goroutines;
  • analysis of incoming values before selecting a representation;
  • statistics for query planning;
  • stable persistence across software upgrades;
  • optional native JIT emission for hot value access.

The original implementation can be understood through this deliberately reduced two-pass model. It is not the complete current Go interface, but it exposes the stable idea more clearly than the optimized implementation:

<syntaxhighlight lang="go">type ColumnStorage interface {

   GetValue(recordID uint32) any
   // analysis pass
   prepare()
   scan(recordID uint32, value any)
   proposeCompression() ColumnStorage
   // build pass
   init(size uint32)
   build(recordID uint32, value any)
   finish()
   Serialize(io.Writer)
   Deserialize(io.Reader)

}</syntaxhighlight>

The optimized code adds batch readers, transaction-aware access, statistics, versioned serialization and JIT hooks around this model; it does not change the analyze → choose representation → build sequence.

The current read side is split into ColumnStorage and a per-goroutine ColumnReader:

<syntaxhighlight lang="go">type ColumnReader interface {

   GetValue(recid uint32) Scmer
   GetValueMulti(recids []uint32, target []Scmer, stride int)
   GetValueRange(recid, count uint32, target []Scmer, stride int)

}</syntaxhighlight>

GetValue supports one random RecordID. GetValueRange reads consecutive IDs, allowing bit-position calculations, binary searches, or decoder initialization to be shared by a complete run. GetValueMulti gathers arbitrary IDs such as an index permutation and detects consecutive regions where the encoding benefits from a sequential path.

Both batch methods write into a caller-provided target with a configurable stride. A scan assembling an interleaved row buffer can therefore fetch one column at a time without allocating an intermediate list for every value.

GetCachedReader() returns a reader optimized for repeated access. Such a reader belongs to one goroutine and must not be shared. Transaction-dependent formats can provide a transaction-bound reader; callers should use shard/table helpers rather than reaching into column maps directly.

Analysis and build phases

Column creation and shard rebuild use a two-pass process so the format can be selected from the actual data before allocating its final representation.

  1. Analyze
    • prepare() resets format-specific observations.
    • Each future value is passed to scan(recordId, value).
    • The implementation records facts such as type, range, scale, distinctness, repetition, NULL/default frequency, run structure, and string dictionary potential.
    • proposeCompression(size) may return a more suitable lossless storage implementation.
    • If the format changes, analysis repeats for the proposed representation.
  2. Build
    • init(size) allocates the final structures.
    • build(recordId, value) encodes every value.
    • finish() releases temporary builder state and finalizes dictionaries or indexes needed by the representation.

This makes compression a data-dependent decision rather than a fixed mapping from SQL type to one physical format. The selected storage still reports a cheap distinct-count estimate for physical planning.

Storage formats

The current implementation includes and composes formats such as:

Format Purpose
StorageSCMER Generic Scheme values and the universal starting representation when no narrower format is yet known.
StorageConst One repeated value without storing it once per record.
StorageSparse Sparse/default-heavy columns represented by exceptional RecordIDs and values.
StorageInt Bit-packed integers whose width and offset are derived from the observed range.
StorageSeq Arithmetic sequences and runs such as ordered identifiers.
StorageDecimal Exact fixed-scale decimals backed by an appropriate integer representation.
StorageFloat IEEE floating-point values.
StorageString Dictionary or buffer-based strings with specialized batch decoding.
StoragePrefix Prefix/suffix decomposition for strings where that representation is applicable; currently experimental for automatic selection.
StorageEnum rANS entropy coding for suitable low-cardinality values.
OverlayBlob External large binary/blob values layered over a base representation.
computed/cache proxy Values derived from other columns or populated on demand while retaining the column-reader contract.

Formats can be nested. For example, a decimal delegates its scaled integer payload to another storage, and string or sequence metadata can itself use compressed integer representations.

Batch execution and late materialization

MemCP scan operators fetch filter columns first and process RecordIDs in batches. Only surviving IDs need map, join, or output columns. Consecutive survivors use GetValueRange; ordered or index-derived ID lists use GetValueMulti.

This is late materialization: complete logical rows are assembled only at an edge that needs them. Filters, projections, aggregation, RecSet operations, and ordered scans can remain column-oriented in the hot path. It also avoids one interface call and one decoder restart per element.

For supported hot expressions, a storage can emit native access code through JITEmit. Unsupported JIT shapes retain the normal reader path and must preserve identical values.

Concurrency and locking

A stable ColumnStorage pointer can be read concurrently, but the shard structures that publish it are protected by the shard lock. Scan and planner code must obtain storages through helpers such as getColumnStorageOrPanic, ColumnReader, or their transaction-aware forms instead of reading the shard's column map directly.

Read-only snapshots use the shard read lock; mutation, rebuild, log replay, delta changes, and index publication require the write lock and concurrency rights. Locks and rights must be released through panic-safe cleanup. Per-goroutine cached readers avoid a lock or mutable decoder shared inside the element loop.

Persistence and compatibility

Every serialized column begins with a permanently assigned magic byte identifying its storage type. Most types then carry a layout-version byte. Binary layout changes must increment that version and retain all older deserializers so existing database files remain readable.

Legacy magic bytes 1, 2, 13, and 40 have no version byte. An incompatible change to one of those layouts requires a new magic byte; the legacy assignment remains a read-only reader forever. Magic values must never be renumbered or reused.

Serialization compatibility is therefore part of the user-visible durability contract, not merely an internal implementation detail. See Persistency and Performance Guarantees.

Measuring compression

An early 55 MiB JSONL experiment produced the following observations. It mixed disk and RAM measurements and did not record schema, cardinalities, process baseline, engine mode, versions, warm-up or repeatability, so it is historical evidence—not a controlled MySQL comparison or a universal ratio.

Representation Reported size
MySQL InnoDB source 55 MiB disk
JSONL export 55 MiB disk
Imported maps in delta storage 233 MiB RAM
Main storage using only StorageSCMER 81 MiB RAM
Integer columns using StorageInt 46 MiB RAM
String columns using StorageString 58 MiB RAM
Combined integer and string encodings 23 MiB RAM

A useful evaluation should report the source dataset and generator, schema and row count, per-column cardinality/range, selected storage formats, main/delta split, MemCP commit, ENGINE, process baseline, measured column sizes, indexes, and repeatable query timings. Compare equal logical data and distinguish allocated RAM, resident memory, serialized bytes, and external backend usage. See Performance Measurement and In-Memory Compression, Columnar Compression Techniques.