perf(fsst): close the vortex-jni gap on encode and decode hot paths#300
Conversation
3e186af to
9d1a51c
Compare
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>
Second review pass — one bug found and fixedRe-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:
Fix: |
Follow-up hot-path pass over the #287 FSST rewrite, iterated against
JavaVsJniFsstBenchmarkwith per-round profiling (-prof stack:lines=10).Results (
-f 3, same machine/run for both sides)vortex-jnijavaFsstEncodejavaFsstDecodeChanges
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-byteloadWordretained for the tail only. The fast path drops the over-long-match guard (a ≤8-byte match cannot crossendthere) and takes the escape literal from the already-loaded word.LossyPerfectHashTable: lookup returns a packedcode << 8 | lengthint (0 = miss) instead of allocating aHashMatchrecord; 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;longestMatchevaluates both tables and selects (cmov-friendly).codeOf(noMatch) == NO_CODEsemantics 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 fromrowEnds.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 ptypeswitchand twoi % capbroadcast modulos removed from the 1M-row loops). Batching is deliberate: a single whole-chunkdecompresscall measured ~1.8x slower per byte (OSR-compiled mega-loop) — documented onROWS_PER_DECODE_BATCH.FsstEncodingDecoderTest(10 tests).Verification
./mvnw verifygreen (all modules, integration tests included) — Rust interop (JavaWritesRustReads,RustWritesJavaReads) confirms wire output unchanged.vortex-reviewerpass: APPROVE; its should-fix (negative tests for the new guards) and nit (>2GB decoded-length guard) are included here.-f 3table; TODO FSST follow-ups refreshed (stale 1.6x-gap phrasing).🤖 Generated with Claude Code