Integer Compression: Difference between revisions

From MemCP
Jump to navigation Jump to search
(Created page with "Traditional Database Management Systems use fixed width integers of 32 or 64 bit that are administered by the database operator. MemCP however uses a different approach: according to a data analysis pass, we detect the maximum bit width of an integer to be encoded into our database. This is done by: * finding the smallest integer in a shard * finding the highest integer * <code>offset = smallest</code> * <code>bitwidth = ⌈ld (highest - smallest)⌉</code> * allocate...")
 
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
 
(3 intermediate revisions by 2 users not shown)
Line 1: Line 1:
Traditional Database Management Systems use fixed width integers of 32 or 64 bit that are administered by the database operator.
<!-- Copyright (C) 2026 Carl-Philip Haensch -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
= Integer Compression =


MemCP however uses a different approach: according to a data analysis pass, we detect the maximum bit width of an integer to be encoded into our database.
MemCP can store an integer column relative to its minimum and bit-pack the remaining range. A range of eight values needs three value bits instead of a fixed 32- or 64-bit slot. Constant, sparse, low-cardinality, and arithmetic-sequence representations may win for other distributions.


This is done by:
== Frame-of-reference layout ==


* finding the smallest integer in a shard
The compact structure is easiest to understand without the later batch and JIT optimizations:
* finding the highest integer
* <code>offset = smallest</code>
* <code>bitwidth = ⌈ld (highest - smallest)⌉</code>
* allocate <code>⌈(num_items * bitwidth) / 64⌉</code> 64-bit integers
* the values are stored at the position <code>item_id * bitwidth</code> inside that memory blob


The analysis and compression is done in the [[Columnar Storage|ColumnStorage]] interface.
* find <code>minimum</code> and <code>maximum</code> inside one rebuilt shard;
* store <code>offset = minimum</code>;
* choose <code>bitwidth = ceil(log2(maximum - minimum + 1))</code> (plus a code when the chosen NULL representation needs one);
* allocate enough 64-bit words for <code>row_count × bitwidth</code> bits;
* encode row <code>item_id</code> at bit position <code>item_id × bitwidth</code> as <code>value - offset</code>.


Integer compression can lead to extensive memory savings:
<pre>type packedFrame struct {
    offset  int64
    bitwidth uint8
    words    []uint64
}


* Binary values use up 1 bit instead of 8 bits or 32 bits per value
bitpos := uint64(itemID) * uint64(frame.bitwidth)
* through the offset handling, unix timestamps can be encoded in 23 bits instead of 32 bits
word := bitpos / 64
* IDs and other foreign keys can be encoded in <code>⌈ld highest_ID⌉</code> bits per item
shift := bitpos % 64
// read/write the value bits; a value may cross into words[word+1]</pre>


Besides the memory savings, this compression can lead to massive speedups due in scenarios where memory bandwith or latency is a bottleneck.
This is a frame-of-reference, fixed-bit packed representation sometimes discussed alongside FOR/PFOR techniques. The current <code>StorageInt</code> does not need a classic Patched-FOR exception list: the observed shard range determines one lossless width, while sparse/default and sequence wrappers handle distributions for which another representation wins. See also [https://github.com/lemire/FastPFor FastPFor] and [https://wwwdb.inf.tu-dresden.de/wp-content/uploads/T_2014_Master_Patrick_Damme.pdf Patrick Damme's work on lightweight compression].


== See also ==
For observed minimum <code>min</code> and maximum <code>max</code>, the encoder stores <code>value - min</code> using enough bits for the complete range. Packed values cross 64-bit word boundaries when necessary; batch readers share bit-position work across consecutive RecordIDs. Binary values can therefore approach one payload bit per row, while IDs and local timestamp ranges use only the width their shard actually needs.
https://github.com/lemire/FastPFor
 
Representation selection happens per rebuilt physical column. Scan code uses range and multi-record batch access so unpacking remains sequential and cache-friendly. Delta values written since the last rebuild are merged with main storage visibility by the scan path.
 
== NULLs, defaults, and alternatives ==
 
NULL/default-heavy columns can store exceptions sparsely instead of paying a full value slot for every row. A constant column needs no per-row payload, while a low-cardinality column may use dictionary or entropy-oriented identifiers. A regular counter or timestamp can be represented as arithmetic runs. The analyzer chooses among these options; “integer column” therefore does not imply one fixed byte width.
 
When a packed range has a spare code, an implementation can reserve it for NULL; distributions where that would increase width may favor a sparse/default wrapper instead. The important contract is lossless SQL NULL round-trip, not one mandatory representation. Format choice is made from the actual shard values during rebuild.
 
The reduced decision looks like this:
 
<pre>if value == nil {
    hasNull = true
    return
}
observe(value)
 
// During final width selection, reserve a code for NULL if it fits.
// Otherwise choose a lossless wrapper/representation that keeps NULL separate.</pre>
 
Example: values from 1,000 through 1,031 need five payload bits after subtracting 1,000. Adding a nullable sentinel may require another code, while a column with only a handful of NULL rows can encode those exceptional RecordIDs separately.
 
For random values spanning most of a 64-bit range, bit packing may save little. Sorting or clustering can create smaller local ranges and longer sequences, but changing application order only for compression must be weighed against write behavior and query access paths.
 
Space and speed depend on range width, null/default density, ordering, shard size, and access pattern. Publish the dataset and storage statistics with any ratio. Persistent layout changes must preserve old magic/version readers.
 
In one early workload, nullable columns that had fallen back to the generic representation contributed to a reported 23 MiB footprint; after NULL-capable integer packing, the same workload was reported at 16 MiB. The old article described that as “40% savings”; arithmetically it is about a 30% reduction from 23 MiB. The dataset and measurement method were not preserved, so the result documents why the NULL code mattered but is not a current benchmark guarantee.
 
See [[Columnar Storage]], [[Sequence Compression]], and [[Performance Measurement]].

Latest revision as of 12:13, 28 August 2026

Integer Compression

MemCP can store an integer column relative to its minimum and bit-pack the remaining range. A range of eight values needs three value bits instead of a fixed 32- or 64-bit slot. Constant, sparse, low-cardinality, and arithmetic-sequence representations may win for other distributions.

Frame-of-reference layout

The compact structure is easiest to understand without the later batch and JIT optimizations:

  • find minimum and maximum inside one rebuilt shard;
  • store offset = minimum;
  • choose bitwidth = ceil(log2(maximum - minimum + 1)) (plus a code when the chosen NULL representation needs one);
  • allocate enough 64-bit words for row_count × bitwidth bits;
  • encode row item_id at bit position item_id × bitwidth as value - offset.
type packedFrame struct {
    offset   int64
    bitwidth uint8
    words    []uint64
}

bitpos := uint64(itemID) * uint64(frame.bitwidth)
word := bitpos / 64
shift := bitpos % 64
// read/write the value bits; a value may cross into words[word+1]

This is a frame-of-reference, fixed-bit packed representation sometimes discussed alongside FOR/PFOR techniques. The current StorageInt does not need a classic Patched-FOR exception list: the observed shard range determines one lossless width, while sparse/default and sequence wrappers handle distributions for which another representation wins. See also FastPFor and Patrick Damme's work on lightweight compression.

For observed minimum min and maximum max, the encoder stores value - min using enough bits for the complete range. Packed values cross 64-bit word boundaries when necessary; batch readers share bit-position work across consecutive RecordIDs. Binary values can therefore approach one payload bit per row, while IDs and local timestamp ranges use only the width their shard actually needs.

Representation selection happens per rebuilt physical column. Scan code uses range and multi-record batch access so unpacking remains sequential and cache-friendly. Delta values written since the last rebuild are merged with main storage visibility by the scan path.

NULLs, defaults, and alternatives

NULL/default-heavy columns can store exceptions sparsely instead of paying a full value slot for every row. A constant column needs no per-row payload, while a low-cardinality column may use dictionary or entropy-oriented identifiers. A regular counter or timestamp can be represented as arithmetic runs. The analyzer chooses among these options; “integer column” therefore does not imply one fixed byte width.

When a packed range has a spare code, an implementation can reserve it for NULL; distributions where that would increase width may favor a sparse/default wrapper instead. The important contract is lossless SQL NULL round-trip, not one mandatory representation. Format choice is made from the actual shard values during rebuild.

The reduced decision looks like this:

if value == nil {
    hasNull = true
    return
}
observe(value)

// During final width selection, reserve a code for NULL if it fits.
// Otherwise choose a lossless wrapper/representation that keeps NULL separate.

Example: values from 1,000 through 1,031 need five payload bits after subtracting 1,000. Adding a nullable sentinel may require another code, while a column with only a handful of NULL rows can encode those exceptional RecordIDs separately.

For random values spanning most of a 64-bit range, bit packing may save little. Sorting or clustering can create smaller local ranges and longer sequences, but changing application order only for compression must be weighed against write behavior and query access paths.

Space and speed depend on range width, null/default density, ordering, shard size, and access pattern. Publish the dataset and storage statistics with any ratio. Persistent layout changes must preserve old magic/version readers.

In one early workload, nullable columns that had fallen back to the generic representation contributed to a reported 23 MiB footprint; after NULL-capable integer packing, the same workload was reported at 16 MiB. The old article described that as “40% savings”; arithmetically it is about a 30% reduction from 23 MiB. The dataset and measurement method were not preserved, so the result documents why the NULL code mattered but is not a current benchmark guarantee.

See Columnar Storage, Sequence Compression, and Performance Measurement.