Skip to content

perf(fsst): close the vortex-jni gap on encode and decode hot paths#300

Merged
dfa1 merged 1 commit into
mainfrom
fsst-hot-path-perf
Jul 24, 2026
Merged

perf(fsst): close the vortex-jni gap on encode and decode hot paths#300
dfa1 merged 1 commit into
mainfrom
fsst-hot-path-perf

Conversation

@dfa1

@dfa1 dfa1 commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Follow-up hot-path pass over the #287 FSST rewrite, iterated against JavaVsJniFsstBenchmark with per-round profiling (-prof stack:lines=10).

Results (-f 3, same machine/run for both sides)

Benchmark Before After vs vortex-jni
javaFsstEncode 1.848 ops/s 3.059 ± 0.430 ops/s 1.6x slower → parity (jni 2.854 ± 0.289)
javaFsstDecode 27.137 ops/s 32.924 ± 0.824 ops/s 1.3x slower → 1.16x faster (jni 28.353 ± 5.283)

Changes

fsst module (encode hot loop):

  • Compressor.compress (both overloads): single unaligned LE 8-byte word load while ≥8 input bytes remain (byteArrayViewVarHandle / LE_LONG); byte-by-byte loadWord retained for the tail only. The fast path drops the over-long-match guard (a ≤8-byte match cannot cross end there) and takes the escape literal from the already-loaded word.
  • LossyPerfectHashTable: lookup returns a packed code << 8 | length int (0 = miss) instead of allocating a HashMatch record; table stored as two adjacent longs per slot (symbol + ignoredBits<<16|code<<8|length, the paper's C layout) so a lookup touches one cache line instead of three parallel arrays; empty slots are all-zero and self-answer no-match.
  • ShortCodeTable/Matcher: pre-packed no-match sentinel makes the fallback one branch-free array read; longestMatch evaluates both tables and selects (cmov-friendly). codeOf(noMatch) == NO_CODE semantics preserved.

writer adapter:

  • FsstEncodingEncoder: all rows compress back-to-back into one shared scratch buffer (was a fresh scratch + exact-size copy per row — 2M allocations per 1M-row chunk write), wire-code remap in a single pass, offsets from rowEnds.

reader adapter:

  • FsstEncodingDecoder: output offsets built as per-ptype prefix sums over the uncompressed-lengths child, and the code stream decoded in 256-row batches (per-row ptype switch and two i % cap broadcast modulos removed from the 1M-row loops). Batching is deliberate: a single whole-chunk decompress call measured ~1.8x slower per byte (OSR-compiled mega-loop) — documented on ROWS_PER_DECODE_BATCH.
  • New malformed-input guards per the security contract (empty children, invalid code offsets, row-count and decoded-length overflow, decoded-vs-claimed length mismatch), each with a negative test in the new FsstEncodingDecoderTest (10 tests).

Verification

  • ./mvnw verify green (all modules, integration tests included) — Rust interop (JavaWritesRustReads, RustWritesJavaReads) confirms wire output unchanged.
  • vortex-reviewer pass: APPROVE; its should-fix (negative tests for the new guards) and nit (>2GB decoded-length guard) are included here.
  • Docs: CHANGELOG entry with the -f 3 table; TODO FSST follow-ups refreshed (stale 1.6x-gap phrasing).

🤖 Generated with Claude Code

@dfa1
dfa1 force-pushed the fsst-hot-path-perf branch 2 times, most recently from 3e186af to 9d1a51c Compare July 23, 2026 19:05
Compressor: single unaligned 8-byte word loads on the fast path (byte-by-byte
loadWord kept for the <8-byte tail), escape literal taken from the loaded word.
LossyPerfectHashTable: packed-int lookup over a two-longs-per-slot table (the
paper's C layout) — one cache line per lookup instead of three parallel-array
reads; empty slots self-answer no-match with no occupancy flag or sentinel.
ShortCodeTable/Matcher: pre-packed no-match slots, one array read per fallback.
FsstEncodingEncoder: all rows compress into one shared scratch (was two heap
allocations plus an extra copy per row), one-pass wire-code remap.
FsstEncodingDecoder: prefix-sum output offsets from the lengths child and
256-row batched decompression (per-row switch/modulo removed; whole-chunk
single call measured 1.8x slower via OSR-compiled mega-loop), plus new
malformed-input guards (empty children, invalid offsets, row-count and
decoded-length overflow, decoded-vs-claimed mismatch) with negative tests.

JavaVsJniFsstBenchmark -f 3: encode 1.848 -> 3.059 +- 0.430 ops/s (jni 2.854,
parity), decode 27.137 -> 32.924 +- 0.824 ops/s (jni 28.353, 1.16x faster).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dfa1
dfa1 force-pushed the fsst-hot-path-perf branch from 9d1a51c to 21b59b2 Compare July 24, 2026 06:06
@dfa1

dfa1 commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

Second review pass — one bug found and fixed

Re-reviewed the full diff line-by-line. Verified the algorithm changes are byte-preserving (compressor fast path, packed hash slots, ShortCodeTable/Matcher, encoder shared-scratch — all equivalent to the prior code, confirmed against the Rust-interop tests). Found and fixed one real defect the first pass missed:

FsstEncodingDecoder: out-of-bounds read on a constant-encoded code-offsets child. The codesOffCap == 1 (constant-broadcast offsets) case was bundled into the batched fast path, whose loop reads readUnsigned(codesOffsetsSeg, batchEndRow) at row indices 256, 512, … — with no modulo. For a column of >256 empty strings (all offsets equal → offsets child collapses to one physical element), that runs off the 1-element segment and throws a raw IndexOutOfBoundsException, violating the security contract (must be VortexException). The pre-rewrite code was safe here via i % codesOffCap; the batched rewrite dropped the modulo.

Fix: cap == 1 now has its own branch — every row range is empty [off, off), so the column decodes to nothing and a non-zero claimed length is rejected as malformed. Added two regression tests (constantCodeOffsetsManyRows_decodesEmptyColumn at n=300, and the malformed-length counterpart). FsstEncodingDecoderTest now 12 tests; ./mvnw test -pl fsst,reader,writer -am green.

@dfa1
dfa1 merged commit 55c7a18 into main Jul 24, 2026
6 checks passed
@dfa1
dfa1 deleted the fsst-hot-path-perf branch July 24, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant