Integer Compression: Difference between revisions

From MemCP
Jump to navigation Jump to search
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
 
(One intermediate revision by the same user 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].


== How NULL values are encoded ==
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.
Usually, databases store NULL values in form of bitmasks. In this case, each value eats up 1 bit for the possibility to become NULL. I will prove that we can do better.


Uncompressed column stores of integers are a continuous column of i.e. 64 bit integers. The problem with 64 bit integers is that every possible value has a meaning. You cannot just take one value (i.e. MAXINT-1) and declare it to be NULL. Malicious users could smuggle that value in and turn a value NULL potentilly breaking the software, in worst case introducing a security risk. So the only chance to decode NULL in 64 bit integers is to introduce 65 bits.
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.


In bit-compressed integer storages however, we exactly know the value range of our stored values. Whenever we know what the highest value is that will be stored into our storage, we can declare the next highest number to be NULL. This is how we do the detection:
== NULLs, defaults, and alternatives ==
'''diff --git a/storage/storage-int.go b/storage/storage-int.go'''
'''index 7a9dff0..4fed6d3 100644'''
'''--- a/storage/storage-int.go'''
'''+++ b/storage/storage-int.go'''
@@ -24,6 +24,8 @@ type StorageInt struct {
        chunk []uint64
        bitsize uint8
        hasNegative bool
+      hasNull bool
+      null uint64 // which value is null
  }


@@ -82,7 +92,15 @@
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.
  func (s *StorageInt) scan(i uint, value scm.Scmer) {
        // storage is so simple, dont need scan
+      if value == nil {
+              s.hasNull = true
+              return
+      }
        v := toInt(value)
+      if v >= int64(s.null) {
+              // mark 1+highest value as null
+              s.null = uint64(v) + 1
+      }
        if v < 0 {
                s.hasNegative = true
                v = -v


@@ -93,20 +111,32 @@
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.
  func (s *StorageInt) init(i uint) {
-      // allocate
+      if s.hasNull {
+              // need an extra bit because of null??
+              l := uint8(bits.Len64(uint64(s.null)))
+              if l > s.bitsize {
+                      s.bitsize = l
+              }
+      }
        if s.hasNegative {
                s.bitsize = s.bitsize + 1
        }
The last patch is to ensure that if we have e.g. booleans with 0 and 1 (1 bit), having NULL will expand the storage to 2 bits. Otherwise, we would have an encodign problem.


The read operation on the storage is fairly easy:
The reduced decision looks like this:
  func (s *StorageInt) getValue(i uint) scm.Scmer {
-      if (s.hasNegative) {
-              return scm.Number(s.getValueInt(i))
-      } else {
-              return scm.Number(s.getValueUInt(i))
+      if (s.hasNegative) { // with sign expansion
+              v := s.getValueInt(i)
+              if s.hasNull && uint64(v) == s.null {
+                      return nil
+              }
+              return scm.Number(v)
+      } else { // without sign expansion
+              v := s.getValueUInt(i)
+              if s.hasNull && v == s.null {
+                      return nil
+              }
+              return scm.Number(v)
        }
  }
So we found a way to encode NULL inside a bit compressed integer storage as a single value.


I measured the RAM usage of our test workload. Before the NULL implementation, some columns of our biggest table had to use <code>StorageSCMER</code> because of the NULL values. This implementation took 23 MB of RAM.
<pre>if value == nil {
    hasNull = true
    return
}
observe(value)


With the new NULL implementation, these columns have been able to be encoded in <code>StorageInt</code> which made the workload occupy only 16 MiB of RAM. '''That’s a 40% savings in overall memory!'''
// During final width selection, reserve a code for NULL if it fits.
// Otherwise choose a lossless wrapper/representation that keeps NULL separate.</pre>


== See also ==
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.


* https://github.com/lemire/FastPFor
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.
* https://wwwdb.inf.tu-dresden.de/wp-content/uploads/T_2014_Master_Patrick_Damme.pdf
 
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.