In-Memory Compression, Columnar Compression Techniques: Difference between revisions

From MemCP
Jump to navigation Jump to search
No edit summary
(Refresh MemCP documentation: accuracy, operational guidance, performance profile and maintained API reference)
 
Line 1: Line 1:
When arranging data from relational tables in a [[Columnar Storage|columnar fashion]], a lot of similar data is placed directly next to each other. This opens up a lot of optimization potenzial with regards to compression (reduce the size) and processing speed (using less time because we have to load less memory chunks)
<!-- Copyright (C) 2026 Carl-Philip Haensch -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
= In-Memory Compression, Columnar Compression Techniques =


The following columnar compression techniques are implemented in MemCP:
MemCP stores each physical column in an encoding selected from its values and access pattern. Implementations include constants, bit-packed integers, exact decimals, floats, dictionaries and low-cardinality entropy coding, shared string prefixes, sparse/default storage, arithmetic sequences, blobs, and index-oriented representations.


* [[Integer Compression]] with a bit-size approach
The goal is to reduce cache lines and memory bandwidth while preserving sequential batch access. Scan paths prefer range and multi-record reads so decoding occurs per batch rather than through one function call per value. Recent writes and deletes live in shard delta structures until rebuild folds them into a newly selected main representation.
* [[Sequence Compression]] for sequences of integers like IDs (1, 2, 3, 4, 5 ...)
* [[Dictionary Compression]] for short strings
* BLOB Zipping and Deduplication for strings longer than 1KiB
* Float Storage for scientific data
* Sparse Storage for columns with lots of NULL values


== Why compressed data can execute faster ==


Also, MemCP is able to [[Index Compression|compress indexes]].
CPU arithmetic is often cheaper than waiting for another cache line. A three- or five-bit packed value needs decoding, but many more values fit in L1/L2/L3 cache and memory transfers. Column layout compounds the effect by avoiding unrelated attributes. Compression helps most when decoding is simple and scans are sequential; it may help less for unpredictable random access or already incompressible values.


== How In-Memory Compression Affects Performance ==
Modern cores can retire many arithmetic instructions while waiting for DRAM. L1 is tiny and fast, later caches are larger and slower, and main-memory bandwidth is shared between cores. Compact sequential columns increase the useful values per cache line and let hardware prefetchers work. This is why MemCP treats lightweight compression as an execution technique, not merely a disk-space feature.
Modern computers are fast. So fast that there is a huge gap between computing speed and memory bandwith and latency in memory-heavvy applications.


== It’s All About Cache ==
The aspiration is that compressed scans remain fast beyond the bandwidth point where wider row representations stop scaling. Whether a concrete query achieves that depends on decoder, selected columns, data distribution, JIT/vectorization coverage, NUMA placement and concurrent traffic.
Cache levels in modern computers refer to the different levels of memory (RAM) within a computer system. A typical computer has three levels of cache: L1, L2, and L3. The L1 cache is the fastest, but also the smallest, and it holds the most recently accessed data. The L2 and L3 caches are larger and slower than the L1, but they can still be accessed more quickly than the main memory.


Data locality is an important concept in programming, as it involves keeping frequently-accessed data in the highest-level cache available. This is because the higher-level caches are faster and can provide quicker access to data than main memory. By keeping data in the highest-level cache available, programs can run faster and more efficiently.
== Selection during rebuild ==


Programmers must consider data locality when writing efficient code in order to maximize the performance of their programs. By taking advantage of the cache levels in modern computers, programmers can ensure that their programs are running as quickly and efficiently as possible.
MemCP analyzes a physical shard column, compares applicable representations, and builds the chosen encoding in a second pass. The choice is local to the observed values rather than a permanent SQL type decision. Delta storage remains write-friendly until a rebuild incorporates it, so users can update a compressed table without rewriting its entire main column per row.


== Our Test Setup ==
Useful signals include minimum/maximum and integer width, distinct values and frequencies, shared string prefixes, default/NULL density, monotonic or arithmetic runs, and whether an index-oriented ordering is valuable.
To test how effective table scans are with our in-memory database, we have the following test setup:


* 10k datasets are loaded into our test table
== Reading and measuring ==
* This takes about 3 MiB of RAM
* We do not use indexes, we do a full table scan to find the data
* We scan for a single value and print it out
* The CPU and RAM are the following:


'''carli@launix-MS-7C51''':'''~/projekte/memcp/server-node-golang'''$ cat /proc/cpuinfo
Late materialization asks only referenced columns to decode. Batch range and multi-record operations avoid virtual calls in the inner row loop, and operators can consume compact identifiers or boundaries before materializing user-visible values.
vendor_id : AuthenticAMD
cpu family : 23
model : 113
model name : AMD Ryzen 9 3900X 12-Core Processor
stepping : 0
microcode : 0x8701013
cpu MHz : 3791.116
cache size : 512 KB
physical id : 0
siblings : 24
core id : 14
cpu cores : 12
apicid : 29
initial apicid : 29
fpu : yes
fpu_exception : yes
cpuid level : 16
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sme sev sev_es
bugs : sysret_ss_attrs spectre_v1 spectre_v2 spec_store_bypass
bogomips : 7600.05
TLB size : 3072 4K pages
clflush size : 64
cache_alignment : 64
address sizes : 43 bits physical, 48 bits virtual
power management: ts ttp tm hwpstate cpb eff_freq_ro [13] [14]


'''carli@launix-MS-7C51''':'''~/projekte/memcp/server-node-golang'''$ cat /proc/meminfo
Compression ratios depend on type, value range, cardinality, ordering, null density, and workload; historical measurements are not product guarantees. Reloadable persistent columns and indexes may be evicted from RAM, while memory-engine rows may not.
MemTotal:      32819940 kB
Hugepagesize:      2048 kB
Hugetlb:              0 kB
In our test setup, we first loaded the table into delta storage and did some measurements. Then we compressed the delta storage into main storage and repeated the measurements. This is what we came up with:


Performance in Delta Storage (row based)
Persisted encodings follow a permanent magic-byte and version contract. A changed binary layout must introduce a new version or magic byte while retaining readers for all earlier formats.
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "3.928691ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "3.920911ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "4.212188ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "5.900366ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.190586ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.197987ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.210748ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.164205ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.161525ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.324479ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.3429ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.33897ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "7.311109ms"
Performance in Main Storage (compressed)
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "3.89169ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "3.899201ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "3.876729ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "5.599179ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.890489ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.774777ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.89611ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.770937ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.783257ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.786928ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.798617ms"
> (scan "PLZ" (lambda (Ort) (equal? Ort "Neugersdorf")) (lambda (PLZ Ort) (print PLZ " - " Ort)))
02727 - Neugersdorf
==> "6.780997ms"


== Interpretation ==
== Historical cache experiment ==
The first queries were a lot faster than later ones. We interpret this as the garbage collector’s work. While in the beginning, most of the RAM is free for use, no time is spent on cleaning up. This changes the more we repeat our measurements and stagnates at a certain point.


As you can see, we get an overall 7% performance boost for compressed columns besides the 10-fold memory savings as described in another article.
An early experiment loaded 10,000 rows (reported around 3 MiB) on an AMD Ryzen 9 3900X with 32 GiB RAM, searched an unindexed postcode column and printed the one matching row. The same Scheme scan was repeated before and after rebuilding delta data into compressed main storage.


When the CPU has to traverse less RAM to achieve the task, This benefits us – even if we have to do a bit of extra work with the CPU to decompress data.
{| class="wikitable"
! Representation !! Recorded timings
|-
| Row-oriented delta state || 3.928691, 3.920911, 4.212188, 5.900366, 7.190586, 7.197987, 7.210748, 7.164205, 7.161525, 7.324479, 7.342900 and 7.338970 ms
|-
| Compressed main columns || 3.891690, 3.899201, 3.876729, 5.599179, 6.890489, 6.774777, 6.896110, 6.770937, 6.783257, 6.786928, 6.798617 and 6.780997 ms
|}
 
The original interpretation attributed the later plateau partly to garbage-collector activity and described roughly a 7% steady-state improvement alongside a large memory reduction. The experiment did not record a current commit, statistical treatment, isolation from printing/GC or independent repetitions. Its raw samples are retained because they motivate the cache-locality design; they must be rerun with the current framework before supporting a product comparison.
 
See [[Columnar Storage]], [[Integer Compression]], [[Dictionary Compression]], [[Sequence Compression]], [[Index Compression]], and [[Memory Management and Eviction]].

Latest revision as of 11:59, 28 August 2026

In-Memory Compression, Columnar Compression Techniques

MemCP stores each physical column in an encoding selected from its values and access pattern. Implementations include constants, bit-packed integers, exact decimals, floats, dictionaries and low-cardinality entropy coding, shared string prefixes, sparse/default storage, arithmetic sequences, blobs, and index-oriented representations.

The goal is to reduce cache lines and memory bandwidth while preserving sequential batch access. Scan paths prefer range and multi-record reads so decoding occurs per batch rather than through one function call per value. Recent writes and deletes live in shard delta structures until rebuild folds them into a newly selected main representation.

Why compressed data can execute faster

CPU arithmetic is often cheaper than waiting for another cache line. A three- or five-bit packed value needs decoding, but many more values fit in L1/L2/L3 cache and memory transfers. Column layout compounds the effect by avoiding unrelated attributes. Compression helps most when decoding is simple and scans are sequential; it may help less for unpredictable random access or already incompressible values.

Modern cores can retire many arithmetic instructions while waiting for DRAM. L1 is tiny and fast, later caches are larger and slower, and main-memory bandwidth is shared between cores. Compact sequential columns increase the useful values per cache line and let hardware prefetchers work. This is why MemCP treats lightweight compression as an execution technique, not merely a disk-space feature.

The aspiration is that compressed scans remain fast beyond the bandwidth point where wider row representations stop scaling. Whether a concrete query achieves that depends on decoder, selected columns, data distribution, JIT/vectorization coverage, NUMA placement and concurrent traffic.

Selection during rebuild

MemCP analyzes a physical shard column, compares applicable representations, and builds the chosen encoding in a second pass. The choice is local to the observed values rather than a permanent SQL type decision. Delta storage remains write-friendly until a rebuild incorporates it, so users can update a compressed table without rewriting its entire main column per row.

Useful signals include minimum/maximum and integer width, distinct values and frequencies, shared string prefixes, default/NULL density, monotonic or arithmetic runs, and whether an index-oriented ordering is valuable.

Reading and measuring

Late materialization asks only referenced columns to decode. Batch range and multi-record operations avoid virtual calls in the inner row loop, and operators can consume compact identifiers or boundaries before materializing user-visible values.

Compression ratios depend on type, value range, cardinality, ordering, null density, and workload; historical measurements are not product guarantees. Reloadable persistent columns and indexes may be evicted from RAM, while memory-engine rows may not.

Persisted encodings follow a permanent magic-byte and version contract. A changed binary layout must introduce a new version or magic byte while retaining readers for all earlier formats.

Historical cache experiment

An early experiment loaded 10,000 rows (reported around 3 MiB) on an AMD Ryzen 9 3900X with 32 GiB RAM, searched an unindexed postcode column and printed the one matching row. The same Scheme scan was repeated before and after rebuilding delta data into compressed main storage.

Representation Recorded timings
Row-oriented delta state 3.928691, 3.920911, 4.212188, 5.900366, 7.190586, 7.197987, 7.210748, 7.164205, 7.161525, 7.324479, 7.342900 and 7.338970 ms
Compressed main columns 3.891690, 3.899201, 3.876729, 5.599179, 6.890489, 6.774777, 6.896110, 6.770937, 6.783257, 6.786928, 6.798617 and 6.780997 ms

The original interpretation attributed the later plateau partly to garbage-collector activity and described roughly a 7% steady-state improvement alongside a large memory reduction. The experiment did not record a current commit, statistical treatment, isolation from printing/GC or independent repetitions. Its raw samples are retained because they motivate the cache-locality design; they must be rerun with the current framework before supporting a product comparison.

See Columnar Storage, Integer Compression, Dictionary Compression, Sequence Compression, Index Compression, and Memory Management and Eviction.