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). The specialization win is real, 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 compression 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 front coding and dictionary encoding combine so naturally.
It has the same sequential dependency as delta (each string is rebuilt from the previous one) and the same fix: restart points. 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. The universal 16 comes straight from the memory hierarchy.
- a full key stored every r entries
- entries in the block
- a binary step weighted α times a linear step
Data table
| Restart interval r | series | Cost (linear-step units) |
|---|---|---|
| 1 | counting comparisons (α = 1) | 20.4 |
| 1.06 | counting comparisons (α = 1) | 20.4 |
| 1.13 | counting comparisons (α = 1) | 20.3 |
| 1.2 | counting comparisons (α = 1) | 20.3 |
| 1.27 | counting comparisons (α = 1) | 20.2 |
| 1.35 | counting comparisons (α = 1) | 20.2 |
| 1.44 | counting comparisons (α = 1) | 20.1 |
| 1.53 | counting comparisons (α = 1) | 20.1 |
| 1.62 | counting comparisons (α = 1) | 20 |
| 1.73 | counting comparisons (α = 1) | 20 |
| 1.83 | counting comparisons (α = 1) | 20 |
| 1.95 | counting comparisons (α = 1) | 19.9 |
| 2.07 | counting comparisons (α = 1) | 19.9 |
| 2.2 | counting comparisons (α = 1) | 19.9 |
| 2.34 | counting comparisons (α = 1) | 19.9 |
| 2.48 | counting comparisons (α = 1) | 19.9 |
| 2.64 | counting comparisons (α = 1) | 19.9 |
| 2.8 | counting comparisons (α = 1) | 19.8 |
| 2.98 | counting comparisons (α = 1) | 19.8 |
| 3.17 | counting comparisons (α = 1) | 19.9 |
| 3.36 | counting comparisons (α = 1) | 19.9 |
| 3.57 | counting comparisons (α = 1) | 19.9 |
| 3.8 | counting comparisons (α = 1) | 19.9 |
| 4.03 | counting comparisons (α = 1) | 19.9 |
| 4.29 | counting comparisons (α = 1) | 20 |
| 4.56 | counting comparisons (α = 1) | 20 |
| 4.84 | counting comparisons (α = 1) | 20.1 |
| 5.14 | counting comparisons (α = 1) | 20.1 |
| 5.46 | counting comparisons (α = 1) | 20.2 |
| 5.81 | counting comparisons (α = 1) | 20.3 |
| 6.17 | counting comparisons (α = 1) | 20.4 |
| 6.55 | counting comparisons (α = 1) | 20.5 |
| 6.96 | counting comparisons (α = 1) | 20.6 |
| 7.4 | counting comparisons (α = 1) | 20.7 |
| 7.86 | counting comparisons (α = 1) | 20.9 |
| 8.35 | counting comparisons (α = 1) | 21 |
| 8.88 | counting comparisons (α = 1) | 21.2 |
| 9.43 | counting comparisons (α = 1) | 21.4 |
| 10 | counting comparisons (α = 1) | 21.6 |
| 10.6 | counting comparisons (α = 1) | 21.8 |
| 11.3 | counting comparisons (α = 1) | 22.1 |
| 12 | counting comparisons (α = 1) | 22.4 |
| 12.8 | counting comparisons (α = 1) | 22.6 |
| 13.6 | counting comparisons (α = 1) | 23 |
| 14.4 | counting comparisons (α = 1) | 23.3 |
| 15.3 | counting comparisons (α = 1) | 23.7 |
| 16.3 | counting comparisons (α = 1) | 24 |
| 17.3 | counting comparisons (α = 1) | 24.5 |
| 18.4 | counting comparisons (α = 1) | 24.9 |
| 19.5 | counting comparisons (α = 1) | 25.4 |
| 20.7 | counting comparisons (α = 1) | 25.9 |
| 22 | counting comparisons (α = 1) | 26.5 |
| 23.4 | counting comparisons (α = 1) | 27.1 |
| 24.9 | counting comparisons (α = 1) | 27.7 |
| 26.4 | counting comparisons (α = 1) | 28.4 |
| 28.1 | counting comparisons (α = 1) | 29.2 |
| 29.9 | counting comparisons (α = 1) | 30 |
| 31.7 | counting comparisons (α = 1) | 30.8 |
| 33.7 | counting comparisons (α = 1) | 31.7 |
| 35.8 | counting comparisons (α = 1) | 32.7 |
| 38.1 | counting comparisons (α = 1) | 33.7 |
| 40.4 | counting comparisons (α = 1) | 34.8 |
| 43 | counting comparisons (α = 1) | 36 |
| 45.6 | counting comparisons (α = 1) | 37.2 |
| 48.5 | counting comparisons (α = 1) | 38.6 |
| 51.5 | counting comparisons (α = 1) | 40 |
| 54.8 | counting comparisons (α = 1) | 41.5 |
| 58.2 | counting comparisons (α = 1) | 43.2 |
| 61.8 | counting comparisons (α = 1) | 44.9 |
| 65.7 | counting comparisons (α = 1) | 46.7 |
| 69.8 | counting comparisons (α = 1) | 48.7 |
| 74.2 | counting comparisons (α = 1) | 50.8 |
| 78.8 | counting comparisons (α = 1) | 53 |
| 83.7 | counting comparisons (α = 1) | 55.4 |
| 89 | counting comparisons (α = 1) | 57.9 |
| 94.5 | counting comparisons (α = 1) | 60.6 |
| 100 | counting comparisons (α = 1) | 63.5 |
| 107 | counting comparisons (α = 1) | 66.5 |
| 113 | counting comparisons (α = 1) | 69.8 |
| 120 | counting comparisons (α = 1) | 73.3 |
| 128 | counting comparisons (α = 1) | 76.9 |
| 1 | counting cache misses (α = 6.5) | 130 |
| 1.06 | counting cache misses (α = 6.5) | 130 |
| 1.13 | counting cache misses (α = 6.5) | 129 |
| 1.2 | counting cache misses (α = 6.5) | 128 |
| 1.27 | counting cache misses (α = 6.5) | 128 |
| 1.35 | counting cache misses (α = 6.5) | 127 |
| 1.44 | counting cache misses (α = 6.5) | 127 |
| 1.53 | counting cache misses (α = 6.5) | 126 |
| 1.62 | counting cache misses (α = 6.5) | 126 |
| 1.73 | counting cache misses (α = 6.5) | 125 |
| 1.83 | counting cache misses (α = 6.5) | 125 |
| 1.95 | counting cache misses (α = 6.5) | 124 |
| 2.07 | counting cache misses (α = 6.5) | 124 |
| 2.2 | counting cache misses (α = 6.5) | 123 |
| 2.34 | counting cache misses (α = 6.5) | 123 |
| 2.48 | counting cache misses (α = 6.5) | 122 |
| 2.64 | counting cache misses (α = 6.5) | 122 |
| 2.8 | counting cache misses (α = 6.5) | 121 |
| 2.98 | counting cache misses (α = 6.5) | 121 |
| 3.17 | counting cache misses (α = 6.5) | 120 |
| 3.36 | counting cache misses (α = 6.5) | 120 |
| 3.57 | counting cache misses (α = 6.5) | 119 |
| 3.8 | counting cache misses (α = 6.5) | 119 |
| 4.03 | counting cache misses (α = 6.5) | 118 |
| 4.29 | counting cache misses (α = 6.5) | 118 |
| 4.56 | counting cache misses (α = 6.5) | 118 |
| 4.84 | counting cache misses (α = 6.5) | 117 |
| 5.14 | counting cache misses (α = 6.5) | 117 |
| 5.46 | counting cache misses (α = 6.5) | 116 |
| 5.81 | counting cache misses (α = 6.5) | 116 |
| 6.17 | counting cache misses (α = 6.5) | 116 |
| 6.55 | counting cache misses (α = 6.5) | 115 |
| 6.96 | counting cache misses (α = 6.5) | 115 |
| 7.4 | counting cache misses (α = 6.5) | 114 |
| 7.86 | counting cache misses (α = 6.5) | 114 |
| 8.35 | counting cache misses (α = 6.5) | 114 |
| 8.88 | counting cache misses (α = 6.5) | 114 |
| 9.43 | counting cache misses (α = 6.5) | 113 |
| 10 | counting cache misses (α = 6.5) | 113 |
| 10.6 | counting cache misses (α = 6.5) | 113 |
| 11.3 | counting cache misses (α = 6.5) | 112 |
| 12 | counting cache misses (α = 6.5) | 112 |
| 12.8 | counting cache misses (α = 6.5) | 112 |
| 13.6 | counting cache misses (α = 6.5) | 112 |
| 14.4 | counting cache misses (α = 6.5) | 112 |
| 15.3 | counting cache misses (α = 6.5) | 112 |
| 16.3 | counting cache misses (α = 6.5) | 112 |
| 17.3 | counting cache misses (α = 6.5) | 111 |
| 18.4 | counting cache misses (α = 6.5) | 111 |
| 19.5 | counting cache misses (α = 6.5) | 111 |
| 20.7 | counting cache misses (α = 6.5) | 111 |
| 22 | counting cache misses (α = 6.5) | 112 |
| 23.4 | counting cache misses (α = 6.5) | 112 |
| 24.9 | counting cache misses (α = 6.5) | 112 |
| 26.4 | counting cache misses (α = 6.5) | 112 |
| 28.1 | counting cache misses (α = 6.5) | 112 |
| 29.9 | counting cache misses (α = 6.5) | 113 |
| 31.7 | counting cache misses (α = 6.5) | 113 |
| 33.7 | counting cache misses (α = 6.5) | 113 |
| 35.8 | counting cache misses (α = 6.5) | 114 |
| 38.1 | counting cache misses (α = 6.5) | 114 |
| 40.4 | counting cache misses (α = 6.5) | 115 |
| 43 | counting cache misses (α = 6.5) | 116 |
| 45.6 | counting cache misses (α = 6.5) | 117 |
| 48.5 | counting cache misses (α = 6.5) | 117 |
| 51.5 | counting cache misses (α = 6.5) | 118 |
| 54.8 | counting cache misses (α = 6.5) | 119 |
| 58.2 | counting cache misses (α = 6.5) | 121 |
| 61.8 | counting cache misses (α = 6.5) | 122 |
| 65.7 | counting cache misses (α = 6.5) | 123 |
| 69.8 | counting cache misses (α = 6.5) | 125 |
| 74.2 | counting cache misses (α = 6.5) | 126 |
| 78.8 | counting cache misses (α = 6.5) | 128 |
| 83.7 | counting cache misses (α = 6.5) | 130 |
| 89 | counting cache misses (α = 6.5) | 132 |
| 94.5 | counting cache misses (α = 6.5) | 134 |
| 100 | counting cache misses (α = 6.5) | 137 |
| 107 | counting cache misses (α = 6.5) | 139 |
| 113 | counting cache misses (α = 6.5) | 142 |
| 120 | counting cache misses (α = 6.5) | 145 |
| 128 | counting 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 Parquet 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 validity bitmap 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
Data table
| Density m/N | series | Bits per row |
|---|---|---|
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.001 | validity bitmap | 1 |
| 0.002 | validity bitmap | 1 |
| 0.002 | validity bitmap | 1 |
| 0.002 | validity bitmap | 1 |
| 0.002 | validity bitmap | 1 |
| 0.003 | validity bitmap | 1 |
| 0.003 | validity bitmap | 1 |
| 0.003 | validity bitmap | 1 |
| 0.004 | validity bitmap | 1 |
| 0.004 | validity bitmap | 1 |
| 0.004 | validity bitmap | 1 |
| 0.005 | validity bitmap | 1 |
| 0.006 | validity bitmap | 1 |
| 0.006 | validity bitmap | 1 |
| 0.007 | validity bitmap | 1 |
| 0.008 | validity bitmap | 1 |
| 0.009 | validity bitmap | 1 |
| 0.01 | validity bitmap | 1 |
| 0.011 | validity bitmap | 1 |
| 0.013 | validity bitmap | 1 |
| 0.014 | validity bitmap | 1 |
| 0.016 | validity bitmap | 1 |
| 0.018 | validity bitmap | 1 |
| 0.02 | validity bitmap | 1 |
| 0.022 | validity bitmap | 1 |
| 0.025 | validity bitmap | 1 |
| 0.028 | validity bitmap | 1 |
| 0.032 | validity bitmap | 1 |
| 0.036 | validity bitmap | 1 |
| 0.04 | validity bitmap | 1 |
| 0.045 | validity bitmap | 1 |
| 0.05 | validity bitmap | 1 |
| 0.056 | validity bitmap | 1 |
| 0.063 | validity bitmap | 1 |
| 0.071 | validity bitmap | 1 |
| 0.079 | validity bitmap | 1 |
| 0.089 | validity bitmap | 1 |
| 0.1 | validity bitmap | 1 |
| 0.112 | validity bitmap | 1 |
| 0.126 | validity bitmap | 1 |
| 0.141 | validity bitmap | 1 |
| 0.158 | validity bitmap | 1 |
| 0.178 | validity bitmap | 1 |
| 0.2 | validity bitmap | 1 |
| 0.224 | validity bitmap | 1 |
| 0.251 | validity bitmap | 1 |
| 0.282 | validity bitmap | 1 |
| 0.316 | validity bitmap | 1 |
| 0.355 | validity bitmap | 1 |
| 0.398 | validity bitmap | 1 |
| 0.447 | validity bitmap | 1 |
| 0.501 | validity bitmap | 1 |
| 0.562 | validity bitmap | 1 |
| 0.631 | validity bitmap | 1 |
| 0.708 | validity bitmap | 1 |
| 0.794 | validity bitmap | 1 |
| 0.891 | validity bitmap | 1 |
| 1 | validity bitmap | 1 |
| 0 | position list | 0.002 |
| 0 | position list | 0.002 |
| 0 | position list | 0.003 |
| 0 | position list | 0.003 |
| 0 | position list | 0.003 |
| 0 | position list | 0.004 |
| 0 | position list | 0.004 |
| 0 | position list | 0.004 |
| 0 | position list | 0.005 |
| 0 | position list | 0.006 |
| 0 | position list | 0.006 |
| 0 | position list | 0.007 |
| 0 | position list | 0.008 |
| 0 | position list | 0.009 |
| 0.001 | position list | 0.01 |
| 0.001 | position list | 0.011 |
| 0.001 | position list | 0.013 |
| 0.001 | position list | 0.014 |
| 0.001 | position list | 0.016 |
| 0.001 | position list | 0.018 |
| 0.001 | position list | 0.02 |
| 0.001 | position list | 0.022 |
| 0.001 | position list | 0.025 |
| 0.001 | position list | 0.028 |
| 0.002 | position list | 0.032 |
| 0.002 | position list | 0.035 |
| 0.002 | position list | 0.04 |
| 0.002 | position list | 0.045 |
| 0.003 | position list | 0.05 |
| 0.003 | position list | 0.056 |
| 0.003 | position list | 0.063 |
| 0.004 | position list | 0.071 |
| 0.004 | position list | 0.079 |
| 0.004 | position list | 0.089 |
| 0.005 | position list | 0.1 |
| 0.006 | position list | 0.112 |
| 0.006 | position list | 0.126 |
| 0.007 | position list | 0.141 |
| 0.008 | position list | 0.158 |
| 0.009 | position list | 0.178 |
| 0.01 | position list | 0.199 |
| 0.011 | position list | 0.224 |
| 0.013 | position list | 0.251 |
| 0.014 | position list | 0.282 |
| 0.016 | position list | 0.316 |
| 0.018 | position list | 0.354 |
| 0.02 | position list | 0.398 |
| 0.022 | position list | 0.446 |
| 0.025 | position list | 0.501 |
| 0.028 | position list | 0.562 |
| 0.032 | position list | 0.63 |
| 0.036 | position list | 0.707 |
| 0.04 | position list | 0.793 |
| 0.045 | position list | 0.89 |
| 0.05 | position list | 0.999 |
| 0.056 | position list | 1.12 |
| 0.063 | position list | 1.26 |
| 0.071 | position list | 1.41 |
| 0.079 | position list | 1.58 |
| 0.089 | position list | 1.78 |
| 0.1 | position list | 1.99 |
| 0.112 | position list | 2.24 |
| 0.126 | position list | 2.51 |
| 0.141 | position list | 2.82 |
| 0.158 | position list | 3.16 |
| 0.178 | position list | 3.54 |
| 0.2 | position list | 3.98 |
| 0.224 | position list | 4.46 |
| 0.251 | position list | 5.01 |
| 0.282 | position list | 5.62 |
| 0.316 | position list | 6.3 |
| 0.355 | position list | 7.07 |
| 0.398 | position list | 7.93 |
| 0.447 | position list | 8.9 |
| 0.501 | position list | 9.99 |
| 0.562 | position list | 11.2 |
| 0.631 | position list | 12.6 |
| 0.708 | position list | 14.1 |
| 0.794 | position list | 15.8 |
| 0.891 | position list | 17.8 |
| 1 | position list | 19.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 IEEE-754 double has 1 sign bit, 11 exponent bits and 52 mantissa 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 order-0 entropy 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 window 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
ALP 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:
- Static rules by type (for example, "strings: dictionary").
- Sample and estimate on about 1% of the values.
- Exhaustive trial of the candidates, keeping the best.
Encodings can be cascaded: dictionary then FOR 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 sampled cascade search.
This sets the cost asymmetry of the stage: the read side is nearly free, and the write side pays for a search. 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.