Skip to content
About
Main content

Bit packing, front coding, nulls and floats

Why do the remaining encodings cost more in decoding than in space, and what does the hardware decide for them?

Bit packing

Idea. Remove the leading zeros uniformly across a block and pack the values next to each other, without aligning them to bytes. If the width is w bits, the ratio is 64 / w (or 32 / w for 32-bit values).

width 3 bits:  5 → 101,  2 → 010,  7 → 111      stored as  101010111...

The space argument is trivial. The interesting part is entirely in decoding.

  • Taking values out of a packed stream needs shifts and masks that cross word boundaries: roughly two to four operations per value in plain scalar code.
  • The vectorized (SIMD) version is fast only if the shift amounts are constants known at compile time. So implementations generate a separate unrolled routine for each bit width: 32 or 64 distinct routines ("kernels") per encoding. A single generic routine with a variable shift runs 3–5× slower.

Note what this really costs. It is not development time. It is instruction cache. Sixty-four specialized kernels per encoding, times several encodings, and the working set of the decode loop no longer fits in the L1 instruction cache (the small fast memory that holds the code the CPU is running). , and the limit on it is a hardware resource that nobody budgets.

Block size is a tug-of-war between two forces:

  • Larger blocks spread the frame and header cost over more values (a fixed cost sets a batch size: wants bigger).
  • Larger blocks are more likely to contain an outlier that widens everything (variance cost: wants smaller).

The balance lands at 64–128 values. This is not a coincidence: 128 values × 4 bits = 512 bits = 64 bytes, exactly one cache line, and the common widths line up with SIMD register boundaries. The hardware's geometry sets a parameter.

Some systems round widths up to 1, 2 or 4 bytes. They give away up to fifteen bits per value in return for a faster, branch-free decode. This is the ratio-versus-speed dial inside one mechanism, and on a local-NVMe workload it is usually the right choice.


Prefix compression (front coding)

Idea. For sorted strings, store the length of the prefix shared with the previous string, plus the part that differs.

https://tradewind.example/parcels/1001      → (0, "https://tradewind.example/parcels/1001")
https://tradewind.example/parcels/1002      → (37, "2")
https://tradewind.example/parcels/1017      → (36, "17")

Sorted keys share long prefixes by construction: URLs, file paths, hierarchical IDs and, notably, the string dictionaries of dictionary encoding. This is why and combine so naturally.

It has the same sequential dependency as (each string is rebuilt from the previous one) and the same fix: . Store a full key every r entries, and keep an array of offsets to the restarts. To search, do a binary search over the restarts and then a linear scan inside one group.

Deriving the restart interval

Counting comparisons, the cost is log₂(N / r) + r / 2 (binary search steps plus the average scan length). Minimizing this naively gives r ≈ 3. Yet every real system uses about 16. The naive derivation is wrong because it counts comparisons instead of cache misses. The binary search over restarts is random access (each step is likely a cache miss). The linear scan inside a group is sequential, so the hardware prefetcher (which loads the next memory lines early) hides its cost.

Give a binary step a weight of α times a linear step. The optimum becomes r ≈ 2α / ln 2. With α between 5 and 8, this gives r between about 14 and 23.

a full key stored every r entries
entries in the block
a binary step weighted α times a linear step
counting comparisons (α = 1)counting cache misses (α = 6.5)
Data table
Search cost by restart interval (N = 10⁶)
Restart interval rseriesCost (linear-step units)
1counting comparisons (α = 1)20.4
1.06counting comparisons (α = 1)20.4
1.13counting comparisons (α = 1)20.3
1.2counting comparisons (α = 1)20.3
1.27counting comparisons (α = 1)20.2
1.35counting comparisons (α = 1)20.2
1.44counting comparisons (α = 1)20.1
1.53counting comparisons (α = 1)20.1
1.62counting comparisons (α = 1)20
1.73counting comparisons (α = 1)20
1.83counting comparisons (α = 1)20
1.95counting comparisons (α = 1)19.9
2.07counting comparisons (α = 1)19.9
2.2counting comparisons (α = 1)19.9
2.34counting comparisons (α = 1)19.9
2.48counting comparisons (α = 1)19.9
2.64counting comparisons (α = 1)19.9
2.8counting comparisons (α = 1)19.8
2.98counting comparisons (α = 1)19.8
3.17counting comparisons (α = 1)19.9
3.36counting comparisons (α = 1)19.9
3.57counting comparisons (α = 1)19.9
3.8counting comparisons (α = 1)19.9
4.03counting comparisons (α = 1)19.9
4.29counting comparisons (α = 1)20
4.56counting comparisons (α = 1)20
4.84counting comparisons (α = 1)20.1
5.14counting comparisons (α = 1)20.1
5.46counting comparisons (α = 1)20.2
5.81counting comparisons (α = 1)20.3
6.17counting comparisons (α = 1)20.4
6.55counting comparisons (α = 1)20.5
6.96counting comparisons (α = 1)20.6
7.4counting comparisons (α = 1)20.7
7.86counting comparisons (α = 1)20.9
8.35counting comparisons (α = 1)21
8.88counting comparisons (α = 1)21.2
9.43counting comparisons (α = 1)21.4
10counting comparisons (α = 1)21.6
10.6counting comparisons (α = 1)21.8
11.3counting comparisons (α = 1)22.1
12counting comparisons (α = 1)22.4
12.8counting comparisons (α = 1)22.6
13.6counting comparisons (α = 1)23
14.4counting comparisons (α = 1)23.3
15.3counting comparisons (α = 1)23.7
16.3counting comparisons (α = 1)24
17.3counting comparisons (α = 1)24.5
18.4counting comparisons (α = 1)24.9
19.5counting comparisons (α = 1)25.4
20.7counting comparisons (α = 1)25.9
22counting comparisons (α = 1)26.5
23.4counting comparisons (α = 1)27.1
24.9counting comparisons (α = 1)27.7
26.4counting comparisons (α = 1)28.4
28.1counting comparisons (α = 1)29.2
29.9counting comparisons (α = 1)30
31.7counting comparisons (α = 1)30.8
33.7counting comparisons (α = 1)31.7
35.8counting comparisons (α = 1)32.7
38.1counting comparisons (α = 1)33.7
40.4counting comparisons (α = 1)34.8
43counting comparisons (α = 1)36
45.6counting comparisons (α = 1)37.2
48.5counting comparisons (α = 1)38.6
51.5counting comparisons (α = 1)40
54.8counting comparisons (α = 1)41.5
58.2counting comparisons (α = 1)43.2
61.8counting comparisons (α = 1)44.9
65.7counting comparisons (α = 1)46.7
69.8counting comparisons (α = 1)48.7
74.2counting comparisons (α = 1)50.8
78.8counting comparisons (α = 1)53
83.7counting comparisons (α = 1)55.4
89counting comparisons (α = 1)57.9
94.5counting comparisons (α = 1)60.6
100counting comparisons (α = 1)63.5
107counting comparisons (α = 1)66.5
113counting comparisons (α = 1)69.8
120counting comparisons (α = 1)73.3
128counting comparisons (α = 1)76.9
1counting cache misses (α = 6.5)130
1.06counting cache misses (α = 6.5)130
1.13counting cache misses (α = 6.5)129
1.2counting cache misses (α = 6.5)128
1.27counting cache misses (α = 6.5)128
1.35counting cache misses (α = 6.5)127
1.44counting cache misses (α = 6.5)127
1.53counting cache misses (α = 6.5)126
1.62counting cache misses (α = 6.5)126
1.73counting cache misses (α = 6.5)125
1.83counting cache misses (α = 6.5)125
1.95counting cache misses (α = 6.5)124
2.07counting cache misses (α = 6.5)124
2.2counting cache misses (α = 6.5)123
2.34counting cache misses (α = 6.5)123
2.48counting cache misses (α = 6.5)122
2.64counting cache misses (α = 6.5)122
2.8counting cache misses (α = 6.5)121
2.98counting cache misses (α = 6.5)121
3.17counting cache misses (α = 6.5)120
3.36counting cache misses (α = 6.5)120
3.57counting cache misses (α = 6.5)119
3.8counting cache misses (α = 6.5)119
4.03counting cache misses (α = 6.5)118
4.29counting cache misses (α = 6.5)118
4.56counting cache misses (α = 6.5)118
4.84counting cache misses (α = 6.5)117
5.14counting cache misses (α = 6.5)117
5.46counting cache misses (α = 6.5)116
5.81counting cache misses (α = 6.5)116
6.17counting cache misses (α = 6.5)116
6.55counting cache misses (α = 6.5)115
6.96counting cache misses (α = 6.5)115
7.4counting cache misses (α = 6.5)114
7.86counting cache misses (α = 6.5)114
8.35counting cache misses (α = 6.5)114
8.88counting cache misses (α = 6.5)114
9.43counting cache misses (α = 6.5)113
10counting cache misses (α = 6.5)113
10.6counting cache misses (α = 6.5)113
11.3counting cache misses (α = 6.5)112
12counting cache misses (α = 6.5)112
12.8counting cache misses (α = 6.5)112
13.6counting cache misses (α = 6.5)112
14.4counting cache misses (α = 6.5)112
15.3counting cache misses (α = 6.5)112
16.3counting cache misses (α = 6.5)112
17.3counting cache misses (α = 6.5)111
18.4counting cache misses (α = 6.5)111
19.5counting cache misses (α = 6.5)111
20.7counting cache misses (α = 6.5)111
22counting cache misses (α = 6.5)112
23.4counting cache misses (α = 6.5)112
24.9counting cache misses (α = 6.5)112
26.4counting cache misses (α = 6.5)112
28.1counting cache misses (α = 6.5)112
29.9counting cache misses (α = 6.5)113
31.7counting cache misses (α = 6.5)113
33.7counting cache misses (α = 6.5)113
35.8counting cache misses (α = 6.5)114
38.1counting cache misses (α = 6.5)114
40.4counting cache misses (α = 6.5)115
43counting cache misses (α = 6.5)116
45.6counting cache misses (α = 6.5)117
48.5counting cache misses (α = 6.5)117
51.5counting cache misses (α = 6.5)118
54.8counting cache misses (α = 6.5)119
58.2counting cache misses (α = 6.5)121
61.8counting cache misses (α = 6.5)122
65.7counting cache misses (α = 6.5)123
69.8counting cache misses (α = 6.5)125
74.2counting cache misses (α = 6.5)126
78.8counting cache misses (α = 6.5)128
83.7counting cache misses (α = 6.5)130
89counting cache misses (α = 6.5)132
94.5counting cache misses (α = 6.5)134
100counting cache misses (α = 6.5)137
107counting cache misses (α = 6.5)139
113counting cache misses (α = 6.5)142
120counting cache misses (α = 6.5)145
128counting cache misses (α = 6.5)148

And it is universal: the data blocks of SSTables (the sorted files of LSM databases), key compression inside B-tree pages, and dictionary pages all use front coding with restarts every 8–32 entries. Three systems, designed independently, at three levels of the stack, with one interval. When independent designs converge this strongly, the cause is a constraint, not a fashion.


Constant and null suppression

These look like degenerate cases. They are in fact where a surprising share of real-world savings comes from, because real tables are full of degenerate columns: deprecated fields, feature flags, tenant identifiers that are constant inside one partition, columns that are entirely null for whole date ranges.

The first requirement: such a column must cost O(1) per chunk, not O(N). The second: its statistics should answer predicates without any read at all (if the chunk's min = max = 5, WHERE x = 7 skips the chunk).

Null representation has a clean crossover

  • A costs N bits (one bit per row).
  • A sparse position list costs m × log₂ N bits for m present values (each position needs log₂ N bits).

They are equal when m / N = 1 / log₂ N. For a chunk of a million rows, log₂ N ≈ 20, so the crossover is at about 5% density. Above 5% of values present: use the bitmap. Below: use positions.

rows in the chunk
present values
validity bitmapposition list
Data table
Bits per row by density of present values (N = 10⁶)
Density m/NseriesBits per row
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.001validity bitmap1
0.002validity bitmap1
0.002validity bitmap1
0.002validity bitmap1
0.002validity bitmap1
0.003validity bitmap1
0.003validity bitmap1
0.003validity bitmap1
0.004validity bitmap1
0.004validity bitmap1
0.004validity bitmap1
0.005validity bitmap1
0.006validity bitmap1
0.006validity bitmap1
0.007validity bitmap1
0.008validity bitmap1
0.009validity bitmap1
0.01validity bitmap1
0.011validity bitmap1
0.013validity bitmap1
0.014validity bitmap1
0.016validity bitmap1
0.018validity bitmap1
0.02validity bitmap1
0.022validity bitmap1
0.025validity bitmap1
0.028validity bitmap1
0.032validity bitmap1
0.036validity bitmap1
0.04validity bitmap1
0.045validity bitmap1
0.05validity bitmap1
0.056validity bitmap1
0.063validity bitmap1
0.071validity bitmap1
0.079validity bitmap1
0.089validity bitmap1
0.1validity bitmap1
0.112validity bitmap1
0.126validity bitmap1
0.141validity bitmap1
0.158validity bitmap1
0.178validity bitmap1
0.2validity bitmap1
0.224validity bitmap1
0.251validity bitmap1
0.282validity bitmap1
0.316validity bitmap1
0.355validity bitmap1
0.398validity bitmap1
0.447validity bitmap1
0.501validity bitmap1
0.562validity bitmap1
0.631validity bitmap1
0.708validity bitmap1
0.794validity bitmap1
0.891validity bitmap1
1validity bitmap1
0position list0.002
0position list0.002
0position list0.003
0position list0.003
0position list0.003
0position list0.004
0position list0.004
0position list0.004
0position list0.005
0position list0.006
0position list0.006
0position list0.007
0position list0.008
0position list0.009
0.001position list0.01
0.001position list0.011
0.001position list0.013
0.001position list0.014
0.001position list0.016
0.001position list0.018
0.001position list0.02
0.001position list0.022
0.001position list0.025
0.001position list0.028
0.002position list0.032
0.002position list0.035
0.002position list0.04
0.002position list0.045
0.003position list0.05
0.003position list0.056
0.003position list0.063
0.004position list0.071
0.004position list0.079
0.004position list0.089
0.005position list0.1
0.006position list0.112
0.006position list0.126
0.007position list0.141
0.008position list0.158
0.009position list0.178
0.01position list0.199
0.011position list0.224
0.013position list0.251
0.014position list0.282
0.016position list0.316
0.018position list0.354
0.02position list0.398
0.022position list0.446
0.025position list0.501
0.028position list0.562
0.032position list0.63
0.036position list0.707
0.04position list0.793
0.045position list0.89
0.05position list0.999
0.056position list1.12
0.063position list1.26
0.071position list1.41
0.079position list1.58
0.089position list1.78
0.1position list1.99
0.112position list2.24
0.126position list2.51
0.141position list2.82
0.158position list3.16
0.178position list3.54
0.2position list3.98
0.224position list4.46
0.251position list5.01
0.282position list5.62
0.316position list6.3
0.355position list7.07
0.398position list7.93
0.447position list8.9
0.501position list9.99
0.562position list11.2
0.631position list12.6
0.708position list14.1
0.794position list15.8
0.891position list17.8
1position list19.9

This is the same dense-versus-sparse crossover that Roaring bitmaps face. Roaring is a compressed bitmap format that, for each chunk of 65,536 values, looks at the data and picks either a plain bitmap or a sorted list of positions. "Inspect the data, then choose the representation per chunk" is the defining idea of this whole stage, applied to a different problem. Two mechanisms from different fields arrived at the same decision with no shared history.


Float compression

Start with why floats resist compression. An double has 1 sign bit, 11 exponent bits and 52 bits. Real measurement data usually has a narrow range of exponents, which compresses well. But the low mantissa bits are essentially random: they are either sensor noise or the binary leftover of a decimal value that has no exact binary form (0.1 cannot be written exactly in binary). The of a typical double column is 30–50 bits, which limits any general-purpose codec to about 1.5–2×.

(For Tradewind this is why lat and lon stay large even in a well-tuned file.)

Gorilla's XOR

Gorilla uses sequential similarity. It XORs each value with the previous one. If the two values share the sign, the exponent and the high mantissa bits, the result has many leading and trailing zero bits. Identical values emit a single bit. Other values emit: the count of leading zeros, the length of the meaningful bits, and the meaningful bits themselves, with a one-bit mode that reuses the of the previous value. On Facebook's metrics, 59% of values collapsed to one bit. The average was 1.37 bytes per point (timestamp plus value) against 16 bytes raw, about 12× overall.

The boundary is specific. XOR works when values change slowly relative to their size. Near a zero crossing, the exponent changes on nearly every sample (going from 1.0 to 0.99 already changes the exponent field), so a signal that oscillates around zero defeats it completely. It also has the same serial dependency as delta.

ALP: decimals in disguise

is the more interesting idea, and the more general lesson. Its observation: most floats in production are decimals in disguise. Prices, sensor readings and percentages are values that began with two or three significant decimal digits and became doubles only because someone picked a column type. So ALP searches for exponents (e, f) such that x × 10^e × 10^−f round-trips exactly to an integer. When it finds them, it stores integers and passes them to the integer tools: frame of reference plus bit-packing. When this fails, it falls back to splitting the mantissa. It beats the XOR family clearly on real data and decodes at several GB/s, because its hot path is the same bit-unpacking routine that everything else in this stage already uses.

The lesson reaches beyond floats: ALP wins by discovering that the real type of the data is not its declared type. The schema said double; the real domain was fixed-point with two decimals. Once the real domain is recovered, ordinary tools apply.


The meta-problem: choosing the encoding

Every mechanism above is a conditional bet, so a writer must choose per column and per block. There are three strategies in practice:

  1. Static rules by type (for example, "strings: dictionary").
  2. Sample and estimate on about 1% of the values.
  3. Exhaustive trial of the candidates, keeping the best.

Encodings can be cascaded: dictionary then then bit-packing, or delta then FOR then bit-packing. So the search space is a tree two or three levels deep, and modern formats do a .

This sets the cost asymmetry of the stage: . It is a search cost, not only a compute cost, and that is a different thing to budget. It is why bulk-loading a well-encoded table can take several times longer than writing the same rows uncompressed, while costing nothing at query time. It also means the right amount of effort is a direct function of the read-to-write ratio: data that is read often deserves more search.