Skip to content

Commit 5391e08

Browse files
dfa1claude
andcommitted
docs(explanation): why-cascading walkthrough with measured numbers
Add a "Why cascading compression" section to docs/explanation.md covering six representative columns (monotonic timestamps, low-card categorical, random doubles, ALP-friendly doubles, RLE ints, high-card strings). Each subsection shows the encoding tree the writer picks with cascade off vs depth-3, ASCII diagrams, and the resulting size. Numbers come from a new CompressionShowcase main class under performance/. Runs 1 M rows per column and prints raw / no-cascade / cascade-depth-3 file sizes — reproducible end-to-end. Includes an "Encodings in play" glossary table so terms like "ALP-friendly" and "FoR-friendly" are defined before the walkthrough. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1a7d1d0 commit 5391e08

2 files changed

Lines changed: 492 additions & 0 deletions

File tree

docs/explanation.md

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,310 @@ At decode time the registry maps the ID string from the Layout node to the right
326326
Custom encodings can be added at build time: `Registry.builder().register(myEncoding).build()`.
327327
Files with unrecognised IDs throw `VortexException` unless the builder enabled `allowUnknown()`.
328328

329+
## Why cascading compression
330+
331+
Vortex stores each column as a tree of encodings. The leaves are raw memory
332+
segments, the inner nodes describe how those bytes turn back into values.
333+
Without cascading, the writer picks one encoding from a static list and stops.
334+
With cascading, the writer samples the data, lets candidate encodings expose
335+
their open child slots, and recursively picks the best inner encoding for
336+
every child. That recursion is what turns a per-encoding sales-pitch into
337+
real compression.
338+
339+
Six representative columns, both paths. Reproduce with:
340+
341+
```
342+
./mvnw verify -DskipTests
343+
java --enable-native-access=ALL-UNNAMED -cp "..." \
344+
io.github.dfa1.vortex.performance.CompressionShowcase
345+
```
346+
347+
Numbers below: 1 000 000 rows per column, JDK 25, vortex-java HEAD.
348+
349+
### The encodings in play, and what they're good for
350+
351+
Before reading the table, you need to know what the writer's pick means. The
352+
labels in the size table aren't marketing terms — each one is a concrete
353+
encoding with a sweet-spot data shape.
354+
355+
| Encoding | What it does | "Friendly" data shape |
356+
|------------------|--------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|
357+
| **Primitive** | Raw little-endian bytes. The baseline. | Truly random data — no overhead, no compression. |
358+
| **FoR** | Frame-of-reference. Subtract `min(col)` from every value; store the minimum + the residuals. | Bounded range: timestamps, monotonic IDs, anything where `max - min` is small. |
359+
| **Bitpacked** | Packs N values into the smallest bit-width that fits the maximum value. | Small-range integers (e.g. FoR residuals, dict codes, small counts). |
360+
| **Dict** | Build a values table; store one code per row pointing into it. | Low-cardinality strings or numbers. **Loses** above ~50 % distinct. |
361+
| **ALP** | Adaptive Lossless float compression. Detects a per-column scale + exponent so that values become small integers, then stores the mantissa. | "Physical world" doubles — prices, sensor readings, percentages. **Loses** on truly random F64. |
362+
| **RLE / RunEnd** | Replace each run of identical values with `(run_end_index, value)`. | Long runs of repeated values — status flags, partition IDs, slow-tick counters. |
363+
| **VarBin** | Concatenate all bytes, store offsets per row. Arrow's classic string layout. | Strings of any cardinality — the safe default for Utf8. |
364+
| **FSST** | Builds a per-column symbol table of the most common byte bigrams, then rewrites the strings as 1-byte codes (or escape + literal). | Short, repetitive-ish strings — log lines, identifiers, JSON keys. Truly random strings beat it. |
365+
| **Constant** | Store the scalar once. Done. | All values identical. |
366+
367+
So "ALP-friendly doubles" = doubles that can be written as `mantissa × 10^-exp`
368+
with a small mantissa. "FoR-friendly" = bounded range. "RLE-friendly" = long
369+
runs. The cascading compressor does the matchmaking; this table tells you
370+
when each match is a win.
371+
372+
### Headline table
373+
374+
```
375+
dataset raw bytes no-cascade cascade(3) ratio
376+
--------------------------------------------------------------------------------
377+
monotonic-timestamps 8 000 000 8 000 664 2 501 832 3.20x
378+
low-card-categorical 4 200 000 1 000 921 1 000 921 4.20x
379+
random-doubles 8 000 000 12 751 764 8 000 672 1.00x
380+
alp-friendly-doubles 8 000 000 8 000 748 1 256 208 6.37x
381+
rle-int 4 000 000 4 000 656 1 604 2 493.77x
382+
highcard-strings 6 000 000 17 977 750 10 551 040 0.57x
383+
```
384+
385+
ratio = raw / cascade(3).
386+
387+
Three patterns to notice before we dig in:
388+
389+
- **Cascading is usually a strict improvement** — never bigger, often dramatically
390+
smaller (RLE: 4 MB → 1.6 KB; ALP-friendly F64: 8 MB → 1.3 MB).
391+
- **Without cascading, the writer can make the file *bigger* than raw.** Static
392+
fallbacks pick the first encoding that "accepts" the dtype, even when it
393+
fails to compress (random F64 → ALP adds overhead; high-cardinality strings
394+
→ Dict blows up the values vector).
395+
- **The cascade isn't magic.** Truly random data ends up close to raw size
396+
because there's no structure to exploit.
397+
398+
### 1. Monotonic timestamps — FoR + Bitpacked
399+
400+
Sensor / log streams produce strictly increasing UNIX timestamps. Each value
401+
is only ~1 second above the previous one, so the *delta* needs ~1 bit, but
402+
the *absolute* value is 64 bits.
403+
404+
```
405+
dtype: I64
406+
sample data: [1767225600, 1767225601, 1767225602, 1767225603, …]
407+
```
408+
409+
**Without cascading**`Primitive` (raw 8 bytes/row) → 8 MB.
410+
411+
```
412+
Primitive(I64)
413+
414+
8 000 000 bytes
415+
```
416+
417+
**With cascading depth 3** — sample shows all-positive small deltas. FoR
418+
subtracts the minimum so residuals fit in a tiny number of bits; the open
419+
residual child cascades into Bitpacked, which packs `~20` bits per row
420+
instead of 64.
421+
422+
```
423+
FoR
424+
ref = 1767225600
425+
426+
Bitpacked
427+
bit_width = 20
428+
429+
~2.5 MB packed
430+
```
431+
432+
3.2× smaller than raw. The same shape covers row IDs, monotonic counters,
433+
millisecond timestamps, anything with bounded local deltas.
434+
435+
### 2. Low-cardinality categorical — Dict
436+
437+
A column of ticker symbols repeats the same 5 distinct strings across a
438+
million rows.
439+
440+
```
441+
dtype: Utf8
442+
sample data: ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "AAPL", "MSFT", …]
443+
```
444+
445+
**Without cascading** — the default codec list picks Dict before VarBin, so
446+
even the no-cascade path catches this one.
447+
448+
```
449+
Dict
450+
/ \
451+
Values Codes
452+
(5 strings) (1 M × U8)
453+
~20 B ~1 MB
454+
```
455+
456+
**With cascading depth 3** — same shape. Dict already wins; codes are tight
457+
(1 byte each because cardinality ≤ 256). 4.2× smaller than raw with no
458+
recursive work needed.
459+
460+
The new cardinality gate (added in 0.6) only kicks in *above* 50 % distinct;
461+
this column is at 0.0005 % distinct, far below the gate.
462+
463+
### 3. Random doubles — Primitive (no compression possible)
464+
465+
The worst case. Truly random F64 values have no exploitable structure.
466+
467+
```
468+
dtype: F64
469+
sample data: [0.733, 0.642, 0.218, 0.875, 0.157, 0.488, 0.999, …]
470+
```
471+
472+
**Without cascading** — the static list tries ALP first. ALP detects no
473+
common scale factor, falls back to its uncompressed path, and the resulting
474+
file is *bigger* than raw (12.7 MB vs 8 MB) thanks to per-row mantissa /
475+
exponent bookkeeping.
476+
477+
```
478+
ALP (degenerate)
479+
/ \ \
480+
Encoded Patch Patch
481+
(no-op) idx values
482+
8 MB ~4.7 MB
483+
```
484+
485+
**With cascading depth 3** — the cost-based selector measures ALP, sees it's
486+
worse than primitive, and picks `Primitive` raw.
487+
488+
```
489+
Primitive(F64)
490+
491+
8 000 000 bytes
492+
```
493+
494+
Lesson: cascading is the cheapest insurance against the writer making the
495+
file *larger*. Without it, "first match" can lose to raw bytes.
496+
497+
### 4. Slowly-varying doubles — ALP + Bitpacked
498+
499+
Stock prices, sensor readings, and most "physical world" doubles drift
500+
slowly. They're representable as `mantissa × 10^-exp` with a small mantissa,
501+
which is exactly what ALP is for.
502+
503+
```
504+
dtype: F64
505+
sample data: [100.05, 100.04, 100.06, 100.07, 100.05, 100.03, …]
506+
```
507+
508+
**Without cascading** — ALP picks the right shape, but its mantissa child
509+
gets emitted as raw `Primitive(I64)`~8 MB.
510+
511+
```
512+
ALP
513+
e=2 f=1
514+
515+
Primitive(I64)
516+
517+
8 000 000 bytes
518+
```
519+
520+
**With cascading depth 3** — the same ALP outer, but its mantissa child
521+
cascades into FoR + Bitpacked. The mantissa range fits in ~10 bits.
522+
523+
```
524+
ALP
525+
e=2 f=1
526+
527+
FoR
528+
529+
Bitpacked
530+
bit_width = 10
531+
532+
~1.25 MB
533+
```
534+
535+
6.4× smaller than raw. This is where Vortex really shines vs Parquet's
536+
fixed page-level codecs: nested arithmetic encodings stack cleanly.
537+
538+
### 5. Run-encoded ints — RunEnd
539+
540+
Long runs of the same value: status flags, monotonic counters that tick
541+
slowly, partition IDs.
542+
543+
```
544+
dtype: I32
545+
sample data: [1,1,1,…(10 000)…,1, 2,2,2,…(10 000)…,2, 3,3,…]
546+
```
547+
548+
**Without cascading**`Primitive` → 4 MB.
549+
550+
```
551+
Primitive(I32)
552+
553+
4 000 000 bytes
554+
```
555+
556+
**With cascading depth 3** — the run structure is detected, RunEnd encodes
557+
each run as `(end_index, value)` and both children compress.
558+
559+
```
560+
RunEnd
561+
/ \
562+
Run ends Values
563+
(100 ints) (100 ints)
564+
~400 B ~400 B
565+
↓ ↓
566+
Bitpacked Bitpacked
567+
(or FoR) (or FoR)
568+
```
569+
570+
**2 493×** smaller than raw — the run is so long that the actual payload
571+
collapses to ~1.6 KB.
572+
573+
### 6. High-cardinality strings — Dict fail / FSST partial win
574+
575+
A million all-distinct random 6-character strings — the kind of column that
576+
turns into a tar pit for dictionary-style encodings.
577+
578+
```
579+
dtype: Utf8
580+
sample data: ["wkzqof", "tdmgxh", "ablrpe", "yvcjsi", …]
581+
```
582+
583+
**Without cascading** — Dict is the first acceptor for Utf8 in the default
584+
codec list. It builds a dictionary nearly as big as the input plus a 4-byte
585+
code per row.
586+
587+
```
588+
Dict
589+
/ \
590+
Values Codes
591+
(1 M strings) (1 M × U32)
592+
~10 MB ~4 MB
593+
= 18 MB (3× raw!)
594+
```
595+
596+
**With cascading depth 3** — the new (0.6) cardinality gate in `DictEncoding`
597+
detects > 50 % distinct on the sample, returns `notApplicable`, and the
598+
cascade rotates to FSST.
599+
600+
```
601+
FSST
602+
╭────────┼────────╮
603+
Symbol Symbol Compressed
604+
table lengths payload
605+
(~2 KB) (~255 B) (~5 MB)
606+
607+
↓ cascade
608+
uncompressed_lens codes_offsets
609+
(Constant: ~4 B) (FoR+Bitpacked)
610+
~5 MB
611+
```
612+
613+
Result: 10.5 MB — still larger than the 6 MB raw, but **42 % smaller than
614+
no-cascade**. Truly random short strings are hard for any encoder (Rust hits
615+
the same wall on this input). Java's FSST symbol-table builder is also less
616+
aggressive than Rust's for now — see `TODO.md`.
617+
618+
### Takeaways
619+
620+
- **Always use `WriteOptions.cascading(...)` unless you have a reason not
621+
to.** The default is `cascading(0)` for legacy compatibility; we'll
622+
probably flip the default in a future major.
623+
- **Cascade depth 3** is the sweet spot in practice. Deeper costs more
624+
encode CPU for tiny diminishing returns; shallower misses key combos
625+
like ALP→FoR→Bitpacked.
626+
- **The encoding tree is the API.** If you `vortex inspect <file>` you'll
627+
see the exact structure of each column, with sizes per node. No black
628+
box.
629+
- **Codec choice is data-dependent.** No single encoding is "best". The
630+
point of cascading is to let the writer admit when it's wrong and try
631+
again.
632+
329633
## Benchmarks
330634

331635
JMH throughput (ops/s = full-file scans per second). Higher is better.

0 commit comments

Comments
 (0)