Skip to content

Commit 1bf6069

Browse files
dfa1claude
andcommitted
docs(fsst): module docs, ADR, TODO follow-ups
PR 8 of #287, the final PR of the FSST rewrite sequence. Documentation-only. - CLAUDE.md: add the fsst module to the module-structure block (zero dependency on core/reader/writer, writer/reader depend on it). - docs/reference.md: new "FSST" subsection under the API reference documenting the module's public surface (CompressorBuilder, Compressor, Decompressor, Symbol) for anyone using it standalone. - TODO.md: follow-up entries for what this rewrite deliberately left out of scope — AVX512/SIMD kernel, true per-row lazy/random-access decode (cross-referencing ADR 0010), and OptFSST (2026 DP-based training follow-up paper, not adopted since it trades training speed for compression ratio in a way that moves off the classic greedy-FSST tradeoff vortex-jni itself uses). - adr/0022-fsst-module-extraction.md (new, Accepted): the architectural record — why a standalone module, why MemorySegment not ByteBuffer at the boundary, the measured before/after numbers, and the risk class the boundary-overrun bug (found and fixed in PR 3) represents for future callers of the branch-free matcher. Added to adr/ADR.md's index. The CHANGELOG entry for this rewrite (with the before/after benchmark table) was already added directly to main in b218740 during the session, so this PR does not duplicate it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 75f5873 commit 1bf6069

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Benchmark classes follow this: `JavaVsJni{Read,Write,Filter}Benchmark`,
2222
## Module structure
2323

2424
```
25+
fsst — io.github.dfa1.vortex.fsst: standalone FSST (Fast Static Symbol Table) string
26+
compression algorithm — Symbol, Compressor/CompressorBuilder, Decompressor/Matcher.
27+
Zero dependency on core/reader/writer/FFM-wire-concerns beyond the JDK's own
28+
java.lang.foreign; writer/reader depend on it, not the other way around.
2529
core — everything lives under `io.github.dfa1.vortex.core.*`:
2630
core.model DType, PType, TimeUnit, EncodingId, LayoutId, ColumnName, ExtensionId, TimeDtype, TimestampDtype
2731
core.io IoBounds, PTypeIO, VortexFormat

TODO.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@
1313
- [ ] Run performance tests on other machines (I have access only to Apple M5)
1414
- [ ] **Vector API adoption** — see [ADR-0005](adr/0005-vector-api-adoption.md).
1515

16+
### FSST follow-ups
17+
18+
Out of scope for the #287 rewrite (which closed most of the `vortex-jni` gap with a scalar,
19+
branch-free algorithm — see [ADR-0022](adr/0022-fsst-module-extraction.md)):
20+
21+
- [ ] **AVX512/SIMD compression kernel** — the paper measures a SIMD kernel as the fastest known
22+
string compressor at that tier, but this project's scalar rewrite already narrowed the encode gap
23+
to `vortex-jni` from 36x to 1.6x. Needs a Vector-API/JDK-incubator decision and its own ADR (cf.
24+
[ADR-0005](adr/0005-vector-api-adoption.md)).
25+
- [ ] **True per-row lazy/random-access decompression** exploiting FSST's headline random-access
26+
property — today `FsstEncodingDecoder.decode()` eagerly materializes the whole column up front
27+
regardless of what is queried. Connects to [ADR-0010](adr/0010-lazy-decode.md) (Lazy decode) but
28+
is a separate initiative.
29+
- [ ] **OptFSST** (2026 arXiv follow-up: DP-based training instead of greedy, ~4x slower training
30+
for 7–17% better compression) — a documented future option, not adopted, since it moves off the
31+
classic greedy-FSST speed/compression tradeoff this rewrite targets (matching what `vortex-jni`
32+
itself uses).
33+
1634
## Security
1735

1836
See [CLAUDE.md §Security contract](CLAUDE.md) for the invariant. Each entry below is either a

adr/0022-fsst-module-extraction.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# ADR 0022: Extract FSST into a standalone module, ported faithfully from the paper
2+
3+
- **Status:** Accepted
4+
- **Date:** 2026-07-20
5+
- **Deciders:** project maintainer
6+
- **Supersedes:**
7+
- **Superseded by:**
8+
- **Related:** [ADR 0005 — Vector API adoption](0005-vector-api-adoption.md), [ADR 0010 — Lazy
9+
decode](0010-lazy-decode.md), [ADR 0017 — In-house FlatBuffers codegen](0017-in-house-flatbuffers-codegen.md)
10+
11+
## Context
12+
13+
`writer/FsstEncodingEncoder` and `reader/FsstEncodingDecoder` held a first-pass, simplified FSST
14+
(Fast Static Symbol Table) implementation. Comparing it against `vortex-jni` (issue #287) surfaced
15+
real gaps against both the original paper (Boncz/Neumann/Leis, "FSST: Fast Random Access String
16+
Compression", PVLDB 13(11), 2020) and the Rust reference (`spiraldb/fsst`, the crate the Rust
17+
`vortex-fsst` wire adapter is built on):
18+
19+
- **Matching.** The old `longestMatch` probed symbol lengths 8 down to 1 in a per-position loop —
20+
up to eight sequential open-addressing hash lookups per input byte, a variable-length loop body
21+
that blocks JIT auto-vectorization (the exact shape CLAUDE.md's hot-loop rule forbids). The
22+
paper's Algorithm 4 ("lossy perfect hashing", §5.1) resolves any match in O(1): a direct
23+
65536-entry array for 0/1/2-byte symbols, and a small hash table for 3-8 byte symbols, combined
24+
branch-free.
25+
- **Training.** The old bottom-up loop always compress-counted the *full* training sample on every
26+
one of its five generations, ranked candidates by plain `count * length`, and never pruned
27+
low-frequency noise. The real reference (`spiraldb/fsst`) additionally uses a growing
28+
per-generation sample fraction (~6% → 100%), a per-generation min-count floor, an 8x gain boost
29+
for single-byte candidates (to suppress escapes), and a final cost-based prune pass.
30+
- **Decode.** The old decoder copied a matched symbol's bytes one at a time in a per-byte loop
31+
instead of the paper's Algorithm 1 trick — one unconditional 8-byte store, advancing the output
32+
cursor by only the symbol's true length.
33+
34+
A zero-dependency Java FSST implementation was searched for and does not exist (only the C
35+
reference `cwida/fsst`, the Rust `spiraldb/fsst`, and a Go port `axiomhq/fsst`). Per that search's
36+
own conclusion, the fix isolates FSST into its own module — mirroring how the Rust reference itself
37+
splits a pure algorithm crate (`spiraldb/fsst`, zero dependencies, no Vortex awareness) from
38+
vortex-rust's thin wire adapter (`vortex-fsst`) — so the compression algorithm can be iterated on
39+
and benchmarked without the Vortex wire-format "shell" in the way.
40+
41+
During the rewrite's own review, a real data-corruption bug was found and fixed: the new
42+
branch-free matcher has no notion of "end of input" (unlike the old bounded loop), so a trained
43+
symbol whose trailing bytes happen to be zero could spuriously match zero-padded bytes past a short
44+
input's true end, silently appending garbage bytes to the decoded output. Confirmed with a concrete
45+
repro (a 3-byte input round-tripping to 8 bytes) before it reached a merged PR. Fixed by an explicit
46+
`pos + length <= end` guard at both call sites that feed the matcher a possibly-short remaining
47+
range (`Compressor.compress` and the training loop's compress-count step) — the paper's own
48+
guidance for a branch-free scalar kernel (§5.2) names exactly this bound as the alternative to a
49+
terminator byte (which is an AVX512-batch-kernel-specific optimization, out of scope here).
50+
51+
## Decision
52+
53+
Extract FSST into a new top-level Maven module, `fsst` (artifact `vortex-fsst`), with zero
54+
dependency on `core`/`reader`/`writer`:
55+
56+
1. **Port the paper faithfully**, plus the real reference's engineering refinements: branch-free
57+
O(1) matching (`ShortCodeTable` for 0/1/2-byte symbols, a 2048-slot lossy perfect hash table for
58+
3-8 byte symbols — matching `spiraldb/fsst`'s L1D-cache-line-split sizing over the paper's
59+
literal 4096, since that Rust crate is the actual `vortex-jni` comparison target); adaptive
60+
byte-size-bounded sampling with a growing per-generation fraction, min-count pruning, the
61+
single-byte gain boost, and the final cost-based prune during training; the unconditional-8-byte-
62+
store decode trick.
63+
2. **Memory boundary: `MemorySegment`, not `ByteBuffer`.** `java.lang.foreign` is a standard JDK
64+
module, not a third-party dependency — using it satisfies "zero deps" exactly as well as
65+
`ByteBuffer` would, while staying consistent with every other module in this codebase (CLAUDE.md:
66+
"Uses FFM (`MemorySegment`/`Arena`) — never JNI or `sun.misc.Unsafe`") and avoiding a dual-API
67+
translation layer with its own parity-testing burden. The module's hot-path `compress`/
68+
`decompress` methods take `MemorySegment` directly; plain `byte[]` overloads exist alongside them
69+
for standalone use with zero FFM ceremony.
70+
3. **`writer`/`reader` become thin wire adapters.** `FsstEncodingEncoder`/`FsstEncodingDecoder` keep
71+
all Vortex-specific plumbing (UTF-8 conversion, `ProtoFSSTMetadata`, `EncodeNode`/`EncodeResult`
72+
assembly, `Arena` allocation) and delegate the algorithm itself to `CompressorBuilder`/
73+
`Compressor`/`Decompressor`. The wire format (`vortex.fsst`) is unchanged — this is a pure
74+
algorithm/performance rewrite, not a wire-format change. The one adapter-side subtlety: the
75+
module numbers codes gain-descending internally (load-bearing for the hash table's
76+
first-writer-wins collision rule), while the wire format requires length-sorted order
77+
(`Compressor#codesSortedByLength()`), so the writer remaps every code the compressor emits
78+
through the wire permutation before writing it out.
79+
80+
## Consequences
81+
82+
### Positive
83+
84+
- Measured, real speedup (`JavaVsJniFsstBenchmark`, unmodified before and after — see the
85+
`[Unreleased]` CHANGELOG entry for the full table): `javaFsstEncode` 0.085 → 1.848 ops/s (~21.7x
86+
faster, closing the gap to `vortex-jni` from 36x slower to 1.6x slower); `javaFsstDecode` 5.243 →
87+
27.137 ops/s (~5.2x faster, gap 6.5x → 1.3x).
88+
- The `fsst` module is independently testable and benchmarkable with zero Vortex file-format
89+
scaffolding in the loop (`FsstEncodingEncoderTest`'s wire-format tests are unaffected; new
90+
`fsst`-module tests exercise the algorithm directly) — the issue's own stated motivation for the
91+
extraction.
92+
- A golden test (`PaperFigure2Test`) pins the paper's own worked example against a hand-built
93+
(not trained) symbol table — an external, human-verifiable fixed point independent of this
94+
project's training heuristics.
95+
96+
### Negative
97+
98+
- Real rework: five new PRs' worth of module surface (`Symbol`, `Matcher`/`ShortCodeTable`/
99+
`LossyPerfectHashTable`, `Sample`/`TrainingGeneration`/`CompressorBuilder`/`Compressor`,
100+
`Decompressor`) versus the ~250-line inline implementation it replaces.
101+
- The compressor's internal (gain-descending) code numbering versus the wire's (length-sorted)
102+
numbering is a second permutation the adapter must get exactly right in both directions (symbol
103+
table population and code-stream remapping) — a subtle class of bug (wrong output, not a crash)
104+
that needs its own dedicated attention in any future change to either ordering.
105+
106+
### Risks to manage
107+
108+
- The branch-free matcher's lack of an input-end bound is a **structural** property, not a bug that
109+
was simply fixed once — any future caller of `Matcher.longestMatch`/`Compressor.compress` that
110+
operates on a range shorter than 8 bytes from the true end of a real buffer must carry the same
111+
`pos + length <= end` bound. This is exactly the kind of thing a future port or refactor could
112+
silently drop, since it is not obviously part of the "core algorithm."
113+
- `LossyPerfectHashTable`'s 2048-slot sizing is a deliberately chosen constant (matching the
114+
`spiraldb/fsst` reference), not derived from first principles for this JVM's cache behavior — if a
115+
future benchmark shows a different size wins on the actual hardware/workload this project cares
116+
about, revisit it deliberately rather than assuming 2048 is universally correct.
117+
118+
## Alternatives considered
119+
120+
- **Reuse an existing Java library.** Searched (GitHub, web) and found none with zero dependencies;
121+
the only real Java-ecosystem candidates were the C reference (via JNI, which this project's FFM
122+
mandate rules out) and ports in other languages entirely (Rust, Go).
123+
- **Keep the old inline implementation and just tune constants.** Rejected: the old matching
124+
algorithm's O(8)-probes-per-byte structure is the dominant cost, not a constant that tuning could
125+
fix — no amount of tuning turns a sequential 8-probe loop into an O(1) branch-free lookup.
126+
- **`ByteBuffer` instead of `MemorySegment` at the module boundary.** Rejected: reintroduces a dual
127+
API (parity tests, two hot-path implementations to keep in sync) for no benefit over
128+
`java.lang.foreign`, which is already a zero-dependency JDK-standard API and is what every other
129+
module in this codebase uses for the same purpose.
130+
131+
## References
132+
133+
- The paper: Boncz, Neumann, Leis, "FSST: Fast Random Access String Compression", PVLDB 13(11),
134+
2020. <https://www.vldb.org/pvldb/vol13/p2649-boncz.pdf>
135+
- Rust reference: [`spiraldb/fsst`](https://github.com/spiraldb/fsst) on GitHub.
136+
- The 8-PR sequence (all on `main`):
137+
- `2b8db4f2` — module skeleton, `Symbol`, `Decompressor`
138+
- `8d778bac` — branch-free matching (`ShortCodeTable` + `LossyPerfectHashTable` + `Matcher`)
139+
- `1003a673``JavaVsJniFsstBenchmark` baseline (pre-rewrite numbers)
140+
- `4158c924` — training (adaptive sampling, min-count pruning, gain boost, final prune) — includes
141+
the boundary-overrun fix described in Context
142+
- `76e26eb6``MemorySegment` hot paths + unconditional-store decode
143+
- `1b9714c7` — rewire `writer`/`reader` adapters onto the `fsst` module
144+
- `75f58734` — golden test for the paper's Figure 2 worked example
145+
- `b218740f` — CHANGELOG before/after benchmark table

adr/ADR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ the decision shipped in (blank = not yet shipped).
3636
| 0019 | Columnar transducer façade for compute | Proposed | |
3737
| 0020 | Jazzer fuzz testing infrastructure | Proposed | |
3838
| 0021 | Cardinality-bounded global dict buffering | Accepted | |
39+
| 0022 | Extract FSST into a standalone module, ported faithfully from the paper | Accepted | |

docs/reference.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ For task-oriented usage see [how-to.md](how-to.md); for design rationale see [ex
88
- [Writer API](#writer-api)
99
- [Scan API](#scan-api)
1010
- [Encoding registry](#encoding-registry)
11+
- [FSST (`io.github.dfa1.vortex.fsst`)](#fsst-iogithubdfa1vortexfsst)
1112
- [Parquet / CSV import](#parquet--csv-import)
1213
- [CLI](#cli)
1314
- [Encoding compatibility](compatibility.md)
@@ -230,6 +231,65 @@ implementations are singletons invoked directly by their `ExtensionId`.
230231

231232
---
232233

234+
## FSST (`io.github.dfa1.vortex.fsst`)
235+
236+
The `vortex-fsst` module is the standalone FSST (Fast Static Symbol Table) string-compression
237+
algorithm, usable independently of Vortex. It depends only on the JDK (`java.lang.foreign`), never
238+
on `core`/`reader`/`writer`; `writer`/`reader` depend on it. The `vortex.fsst` encoding adapter is
239+
one caller — the module itself knows nothing of the Vortex wire format. See
240+
[ADR 0022](../adr/0022-fsst-module-extraction.md).
241+
242+
Compress: train a `Compressor` over a corpus, then compress rows against its table. Decompress:
243+
build a `Decompressor` (from the trained `Compressor`, or from raw table arrays). Hot-path methods
244+
accept `MemorySegment` (FFM-native, matching the rest of the codebase); `byte[]` overloads exist for
245+
zero-ceremony standalone use, and both paths produce identical output for the same input.
246+
247+
### `CompressorBuilder`
248+
249+
| Method | Notes |
250+
|-------------------------|-----------------------------------------------------------------------------|
251+
| `new CompressorBuilder()` | Fresh builder using a fixed reproducible default seed |
252+
| `seed(long)` | Override the training-sample seed; returns `this` for chaining |
253+
| `train(byte[][] rows)` | Runs the bottom-up generation loop, returns a trained `Compressor` (deterministic in rows + seed) |
254+
255+
### `Compressor`
256+
257+
Immutable trained symbol table (up to 255 codes; `0xFF` is the escape). Produced by
258+
`CompressorBuilder.train(byte[][])`.
259+
260+
| Method | Notes |
261+
|-----------------------------------------------------------|-----------------------------------------------------------------------|
262+
| `symbolCount()` | Number of symbols in the table (0–255) |
263+
| `packedSymbol(int code)` | The symbol's bytes packed LSB-first into a `long` |
264+
| `symbolLength(int code)` | The symbol's length in bytes (1–8) |
265+
| `codesSortedByLength()` | Code permutation, multi-byte length-ascending then all length-1 last |
266+
| `toDecompressor()` | A `Decompressor` bound to this table |
267+
| `compress(byte[] in, int start, int end, byte[] out, long outPos)` | Greedy longest-match compress; size `out``2 * (end - start)` |
268+
| `compress(MemorySegment in, long start, long end, MemorySegment out, long outPos)` | FFM-native hot path; same sizing rule |
269+
270+
### `Decompressor`
271+
272+
Decodes an FSST code stream against a trained table (parallel arrays indexed by code).
273+
274+
| Method | Notes |
275+
|-----------------------------------------------------------|-----------------------------------------------------------------------|
276+
| `static of(long[] packedSymbols, int[] lengths)` | Bind to a table; arrays are referenced, not copied, and must match in length |
277+
| `static final int ESCAPE` | The escape code (`0xFF`) |
278+
| `decompress(byte[] in, int start, int end, byte[] out, long outPos)` | Decode; size `out``8 * (end - start)` |
279+
| `decompress(MemorySegment in, long start, long end, MemorySegment out, long outPos)` | Unconditional-8-byte-store decode; caller MUST allocate `out` with ≥ 7 bytes of trailing slack |
280+
281+
### `Symbol`
282+
283+
`record Symbol(long packedBytes, int length)` — up to 8 bytes packed LSB-first (byte `k` at bit
284+
`k * 8`), length in 1–8.
285+
286+
| Method | Notes |
287+
|-------------------------------------|-------------------------------------------------------------|
288+
| `static of(byte[] data, int offset, int length)` | Pack `length` bytes at `data[offset..]` LSB-first |
289+
| `byteAt(int i)` | The `i`-th byte (0-indexed; byte 0 is the LSB) |
290+
291+
---
292+
233293
## Layout registry
234294

235295
### `LayoutRegistry` (`io.github.dfa1.vortex.reader.layout`)

0 commit comments

Comments
 (0)