Skip to content

Commit 9d1a51c

Browse files
dfa1claude
andcommitted
perf(fsst): close the vortex-jni gap on encode and decode hot paths
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>
1 parent 1bf6069 commit 9d1a51c

10 files changed

Lines changed: 589 additions & 130 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
Measured with `JavaVsJniFsstBenchmark` ([1003a673](https://github.com/dfa1/vortex-java/commit/1003a673)), unmodified before and after.
2020

21+
- `vortex.fsst` hot paths close the remaining `vortex-jni` gap on both sides: single 8-byte word loads and a one-cache-line packed hash-slot layout in the compressor, one shared scratch buffer in the encoder (was two heap allocations per row), and prefix-sum output offsets plus 256-row batched decompression in the decoder (was a per-row offset `switch` and two broadcast modulos per row). ([#300](https://github.com/dfa1/vortex-java/pull/300))
22+
23+
| Benchmark | Before | After | `vortex-java` vs `vortex-jni` |
24+
|---|---|---|---|
25+
| `javaFsstEncode` | 1.848 ops/s | 3.059 ± 0.430 ops/s | 1.6x slower → parity (1.07x, overlapping error) |
26+
| `javaFsstDecode` | 27.137 ops/s | 32.924 ± 0.824 ops/s | 1.3x slower → 1.16x faster |
27+
28+
Measured with `JavaVsJniFsstBenchmark` `-f 3` (jni: encode 2.854 ± 0.289, decode 28.353 ± 5.283 ops/s on the same machine and run).
29+
2130
### Fixed
2231

2332
- `ParquetImporter` detects duplicate column names in the source Parquet schema and throws a clear message naming the duplicate(s) and the source file, instead of a confusing `VortexWriter` internal-invariant error several frames removed from the actual cause. ([f2c05e57](https://github.com/dfa1/vortex-java/commit/f2c05e57))

TODO.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,10 @@
1515

1616
### FSST follow-ups
1717

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)):
18+
Out of scope for the #287 rewrite (which, together with the follow-up hot-path pass, closed the
19+
`vortex-jni` gap with a scalar, branch-free algorithm — encode at parity, decode ~1.16x faster on
20+
`JavaVsJniFsstBenchmark` — see [ADR-0022](adr/0022-fsst-module-extraction.md)):
2021

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)).
2522
- [ ] **True per-row lazy/random-access decompression** exploiting FSST's headline random-access
2623
property — today `FsstEncodingDecoder.decode()` eagerly materializes the whole column up front
2724
regardless of what is queried. Connects to [ADR-0010](adr/0010-lazy-decode.md) (Lazy decode) but

fsst/src/main/java/io/github/dfa1/vortex/fsst/Compressor.java

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import java.lang.foreign.MemorySegment;
44
import java.lang.foreign.ValueLayout;
5+
import java.lang.invoke.MethodHandles;
6+
import java.lang.invoke.VarHandle;
7+
import java.nio.ByteOrder;
58
import java.util.ArrayList;
69
import java.util.List;
710

@@ -23,7 +26,13 @@ public final class Compressor {
2326
/// `core`'s `VortexFormat.LE_LONG` so this module keeps its zero dependency on `core`; the FSST
2427
/// wire format packs symbol bytes LSB-first, so all `MemorySegment` reads/writes are little-endian.
2528
static final ValueLayout.OfLong LE_LONG =
26-
ValueLayout.JAVA_LONG_UNALIGNED.withOrder(java.nio.ByteOrder.LITTLE_ENDIAN);
29+
ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
30+
31+
/// Little-endian unaligned `long` view over a `byte[]`, the JIT-intrinsified way to load an
32+
/// 8-byte input word in one instruction on the `byte[]` compress fast path (the byte-by-byte
33+
/// [#loadWord(byte[], int, int)] assembly is 8 loads + shifts and is kept for the tail only).
34+
private static final VarHandle LONG_LE_BYTES =
35+
MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
2736

2837
private final List<Symbol> symbolsByGainDescending;
2938
private final Matcher matcher;
@@ -142,8 +151,27 @@ public Decompressor toDecompressor() {
142151
public long compress(byte[] input, int start, int end, byte[] out, long outPos) {
143152
int pos = start;
144153
int outIndex = (int) outPos;
154+
// Fast path: while 8 real input bytes remain, load the word in one intrinsified read. No
155+
// over-long-match guard is needed here — a match is at most 8 bytes, so it can never reach
156+
// past end while pos <= end - 8.
157+
int fastEnd = end - 8;
158+
while (pos <= fastEnd) {
159+
long word = (long) LONG_LE_BYTES.get(input, pos);
160+
int packedMatch = matcher.longestMatch(word);
161+
int length = Matcher.lengthOf(packedMatch);
162+
if (length > 0) {
163+
out[outIndex++] = (byte) Matcher.codeOf(packedMatch);
164+
pos += length;
165+
} else {
166+
// The escaped literal is the word's low byte — already loaded, no re-read.
167+
out[outIndex++] = (byte) ESCAPE;
168+
out[outIndex++] = (byte) word;
169+
pos++;
170+
}
171+
}
145172
while (pos < end) {
146-
int packedMatch = matcher.longestMatch(loadWord(input, pos, end));
173+
long word = loadWord(input, pos, end);
174+
int packedMatch = matcher.longestMatch(word);
147175
int length = Matcher.lengthOf(packedMatch);
148176
// Reject an over-long match: loadWord zero-pads bytes past end, and the branch-free
149177
// Matcher has no notion of end, so a symbol whose trailing bytes are zero can spuriously
@@ -156,7 +184,7 @@ public long compress(byte[] input, int start, int end, byte[] out, long outPos)
156184
pos += length;
157185
} else {
158186
out[outIndex++] = (byte) ESCAPE;
159-
out[outIndex++] = input[pos];
187+
out[outIndex++] = (byte) word;
160188
pos++;
161189
}
162190
}
@@ -187,8 +215,27 @@ public long compress(byte[] input, int start, int end, byte[] out, long outPos)
187215
public long compress(MemorySegment input, long start, long end, MemorySegment out, long outPos) {
188216
long pos = start;
189217
long outIndex = outPos;
218+
// Fast path: while 8 readable bytes remain (bounded by both end and the segment's own
219+
// size — the segment may end exactly at end with no trailing slack), load the word in one
220+
// unaligned read. No over-long-match guard is needed here — a match is at most 8 bytes.
221+
long fastEnd = Math.min(end, input.byteSize()) - 8;
222+
while (pos <= fastEnd) {
223+
long word = input.get(LE_LONG, pos);
224+
int packedMatch = matcher.longestMatch(word);
225+
int length = Matcher.lengthOf(packedMatch);
226+
if (length > 0) {
227+
out.set(ValueLayout.JAVA_BYTE, outIndex++, (byte) Matcher.codeOf(packedMatch));
228+
pos += length;
229+
} else {
230+
// The escaped literal is the word's low byte — already loaded, no re-read.
231+
out.set(ValueLayout.JAVA_BYTE, outIndex++, (byte) ESCAPE);
232+
out.set(ValueLayout.JAVA_BYTE, outIndex++, (byte) word);
233+
pos++;
234+
}
235+
}
190236
while (pos < end) {
191-
int packedMatch = matcher.longestMatch(loadWord(input, pos, end));
237+
long word = loadWord(input, pos, end);
238+
int packedMatch = matcher.longestMatch(word);
192239
int length = Matcher.lengthOf(packedMatch);
193240
// Same boundary guard as the byte[] overload: reject a match that reaches past the real
194241
// input end, since loadWord zero-pads and the branch-free Matcher has no notion of end.
@@ -197,7 +244,7 @@ public long compress(MemorySegment input, long start, long end, MemorySegment ou
197244
pos += length;
198245
} else {
199246
out.set(ValueLayout.JAVA_BYTE, outIndex++, (byte) ESCAPE);
200-
out.set(ValueLayout.JAVA_BYTE, outIndex++, input.get(ValueLayout.JAVA_BYTE, pos));
247+
out.set(ValueLayout.JAVA_BYTE, outIndex++, (byte) word);
201248
pos++;
202249
}
203250
}

fsst/src/main/java/io/github/dfa1/vortex/fsst/LossyPerfectHashTable.java

Lines changed: 34 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,21 @@ final class LossyPerfectHashTable {
3535
/// Low three bytes of the input word — the only bytes the hash keys on.
3636
private static final long PREFIX_MASK = 0x00FF_FFFFL;
3737

38-
/// Per-slot packed symbol bytes, LSB-first ([Symbol] convention). Meaningful only where the
39-
/// corresponding [#occupied] entry is set.
40-
private final long[] packedBytes;
41-
42-
/// Per-slot mask `~0L >>> ignoredBits` (`ignoredBits = 64 - 8 * length`) applied to an input
43-
/// word before comparing, clearing the high bytes past the candidate's length.
44-
private final long[] keepMask;
45-
46-
/// Per-slot symbol length in bytes, 3-8.
47-
private final int[] lengths;
48-
49-
/// Per-slot code (the symbol's list index), meaningful only where [#occupied] is set.
50-
private final int[] codes;
51-
52-
/// Whether each slot holds a candidate.
53-
private final boolean[] occupied;
54-
55-
private LossyPerfectHashTable(long[] packedBytes, long[] keepMask, int[] lengths, int[] codes,
56-
boolean[] occupied) {
57-
this.packedBytes = packedBytes;
58-
this.keepMask = keepMask;
59-
this.lengths = lengths;
60-
this.codes = codes;
61-
this.occupied = occupied;
38+
/// Slot table with two adjacent longs per slot (the FSST paper's C layout): `slots[2 * s]` is
39+
/// the candidate's packed symbol bytes (LSB-first, [Symbol] convention) and `slots[2 * s + 1]`
40+
/// is its metadata `ignoredBits << 16 | code << 8 | length` (`ignoredBits = 64 - 8 * length`).
41+
/// A lookup derives the keep-mask from the ignored-bits with one shift instead of loading a
42+
/// separate mask array, so the whole 16-byte slot lands in a single cache line — one memory
43+
/// touch per lookup instead of three parallel-array reads across three lines.
44+
///
45+
/// Empty slots are all-zero: metadata 0 makes the keep-mask `~0L` and the symbol 0, so an
46+
/// input word of exactly 0 can "hit" an empty slot — harmlessly, because the returned low 16
47+
/// bits (`code << 8 | length`) are then 0, which is precisely the "no match" answer. No
48+
/// occupancy flag or sentinel is needed.
49+
private final long[] slots;
50+
51+
private LossyPerfectHashTable(long[] slots) {
52+
this.slots = slots;
6253
}
6354

6455
/// Builds the table from the trained symbols in descending-gain order, keeping only those of
@@ -76,43 +67,41 @@ private LossyPerfectHashTable(long[] packedBytes, long[] keepMask, int[] lengths
7667
/// @param symbolsByGainDescending the trained symbols, code = list index, gain-descending
7768
/// @return a hash table resolving 3-8 byte matches with first-writer-wins on collision
7869
static LossyPerfectHashTable of(List<Symbol> symbolsByGainDescending) {
79-
long[] packedBytes = new long[SLOTS];
80-
long[] keepMask = new long[SLOTS];
81-
int[] lengths = new int[SLOTS];
82-
int[] codes = new int[SLOTS];
83-
boolean[] occupied = new boolean[SLOTS];
70+
long[] slots = new long[2 * SLOTS];
8471
for (int code = 0; code < symbolsByGainDescending.size(); code++) {
8572
Symbol symbol = symbolsByGainDescending.get(code);
8673
if (symbol.length() < 3) {
8774
continue; // Length 1-2 belongs to ShortCodeTable.
8875
}
8976
int slot = slotFor(symbol.packedBytes());
90-
if (occupied[slot]) {
77+
if (slots[2 * slot + 1] != 0) {
9178
continue; // First writer (higher gain) wins; skip the collision.
9279
}
93-
occupied[slot] = true;
94-
packedBytes[slot] = symbol.packedBytes();
95-
keepMask[slot] = keepMaskFor(symbol.length());
96-
lengths[slot] = symbol.length();
97-
codes[slot] = code;
80+
long ignoredBits = 64L - 8 * symbol.length();
81+
slots[2 * slot] = symbol.packedBytes();
82+
slots[2 * slot + 1] = ignoredBits << 16 | (long) code << 8 | symbol.length();
9883
}
99-
return new LossyPerfectHashTable(packedBytes, keepMask, lengths, codes, occupied);
84+
return new LossyPerfectHashTable(slots);
10085
}
10186

102-
/// Looks up `word` and reports whether a stored 3-8 byte symbol really matches it.
87+
/// Looks up `word` and returns the matched symbol as `code << 8 | length`, or 0 when no stored
88+
/// 3-8 byte symbol matches.
10389
///
10490
/// The input word's first three bytes select one slot; the candidate there matches only if the
105-
/// masked compare passes (`(word & keepMask) == candidate.packedBytes`), which rules out both
106-
/// hash collisions with unrelated bytes and empty slots. On a miss the returned [HashMatch] has
107-
/// `hit == false` and the caller falls back to [ShortCodeTable].
91+
/// masked compare passes (`(word & (~0L >>> ignoredBits)) == candidate.packedBytes`), which
92+
/// rules out hash collisions with unrelated bytes. An empty slot can only "hit" an all-zero
93+
/// input word, and then still answers 0 (its metadata is 0), which is the no-match result. A
94+
/// stored symbol's length is 3-8, so a real hit is never 0 and the caller can test the result
95+
/// directly. On a miss the caller falls back to [ShortCodeTable].
10896
///
10997
/// @param word an 8-byte little-endian input word starting at the current match position, with
11098
/// any bytes past the remaining input already zero-padded by the caller
111-
/// @return the match result: `hit`, and when hit the matched `code` and `length`
112-
HashMatch lookup(long word) {
113-
int slot = slotFor(word);
114-
boolean hit = occupied[slot] && (word & keepMask[slot]) == packedBytes[slot];
115-
return new HashMatch(hit, codes[slot], lengths[slot]);
99+
/// @return the match as `code << 8 | length`, or 0 when there is no match
100+
int lookup(long word) {
101+
int slot = slotFor(word) << 1;
102+
long symbol = slots[slot];
103+
long meta = slots[slot + 1];
104+
return (word & (~0L >>> (int) (meta >>> 16))) == symbol ? (int) (meta & 0xFFFF) : 0;
116105
}
117106

118107
/// Computes the slot index a word hashes to, keyed on its first three bytes. Package-visible so
@@ -126,18 +115,4 @@ static int slotFor(long word) {
126115
return (int) (mixed >>> 32) & SLOT_MASK;
127116
}
128117

129-
private static long keepMaskFor(int length) {
130-
int ignoredBits = 64 - 8 * length;
131-
return ~0L >>> ignoredBits;
132-
}
133-
134-
/// Result of a [LossyPerfectHashTable#lookup(long)]: whether a 3-8 byte symbol matched and, if
135-
/// so, its code and length. When `hit` is false, `code` and `length` are unspecified and the
136-
/// caller must ignore them.
137-
///
138-
/// @param hit whether a stored symbol really matched the input word
139-
/// @param code the matched symbol code, valid only when `hit`
140-
/// @param length the matched symbol length in bytes (3-8), valid only when `hit`
141-
record HashMatch(boolean hit, int code, int length) {
142-
}
143118
}

fsst/src/main/java/io/github/dfa1/vortex/fsst/Matcher.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ public static Matcher of(List<Symbol> symbolsByGainDescending) {
5555
/// any bytes past the remaining input already zero-padded by the caller
5656
/// @return the longest match as `code << 8 | length`; length 0 signals "no match, escape"
5757
public int longestMatch(long word) {
58-
LossyPerfectHashTable.HashMatch hashMatch = hashTable.lookup(word);
59-
if (hashMatch.hit()) {
60-
return hashMatch.code() << 8 | hashMatch.length();
61-
}
62-
return shortCodes.codeFor(word) << 8 | shortCodes.lengthFor(word);
58+
// Both tables are read unconditionally so the select below has no side to skip — the JIT
59+
// can lower it to a conditional move instead of a data-dependent branch, which matters
60+
// because hash hit/miss alternates unpredictably across input positions. The extra
61+
// short-code read on a hash hit is one L1 load into a 256 KB table.
62+
int hashMatch = hashTable.lookup(word);
63+
int shortMatch = shortCodes.packedFor(word);
64+
return hashMatch != 0 ? hashMatch : shortMatch;
6365
}
6466

6567
/// Extracts the code from a packed [#longestMatch(long)] result.

fsst/src/main/java/io/github/dfa1/vortex/fsst/ShortCodeTable.java

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.dfa1.vortex.fsst;
22

3+
import java.util.Arrays;
34
import java.util.List;
45

56
/// Direct-indexed table resolving the shortest FSST matches — length 0 (no match), 1, or 2 —
@@ -25,7 +26,12 @@ final class ShortCodeTable {
2526
/// codes are `0..254` (`0xFF` is the escape), so `-1` can never collide with a real code.
2627
static final int NO_CODE = -1;
2728

28-
/// Packed `code << 8 | length` per 16-bit key. A zero length marks "no match".
29+
/// Packed no-match value stored in unpopulated slots: `NO_CODE << 8 | 0`. Storing the sentinel
30+
/// pre-packed lets [#packedFor(long)] return the slot verbatim — one array read, no branch —
31+
/// and an arithmetic `>> 8` recovers [#NO_CODE] while `& 0xFF` recovers length 0.
32+
private static final int NO_MATCH = NO_CODE << 8;
33+
34+
/// Packed `code << 8 | length` per 16-bit key. A zero length marks "no match" ([#NO_MATCH]).
2935
private final int[] slots;
3036

3137
private ShortCodeTable(int[] slots) {
@@ -46,6 +52,7 @@ private ShortCodeTable(int[] slots) {
4652
/// @return a table resolving 0/1/2-byte matches for any two-byte input prefix
4753
static ShortCodeTable of(List<Symbol> symbolsByGainDescending) {
4854
int[] slots = new int[SLOTS];
55+
Arrays.fill(slots, NO_MATCH);
4956
for (int code = 0; code < symbolsByGainDescending.size(); code++) {
5057
Symbol symbol = symbolsByGainDescending.get(code);
5158
if (symbol.length() == 1) {
@@ -69,23 +76,32 @@ static ShortCodeTable of(List<Symbol> symbolsByGainDescending) {
6976
return new ShortCodeTable(slots);
7077
}
7178

72-
/// Returns the code matched by the low two bytes of `word`, or [#NO_CODE] if none. One array
73-
/// read, no branching.
79+
/// Returns the match for the low two bytes of `word` as `code << 8 | length`, or
80+
/// `NO_CODE << 8` (length 0) when there is no length-1 or length-2 match. One array read, no
81+
/// branching — the slot value is returned verbatim, which is what makes this the hot path's
82+
/// fallback of choice.
83+
///
84+
/// @param word an input word; only its low 16 bits (first two input bytes) are consulted
85+
/// @return the match as `code << 8 | length`; length 0 (and code [#NO_CODE]) means no match
86+
int packedFor(long word) {
87+
return slots[(int) (word & 0xFFFF)];
88+
}
89+
90+
/// Returns the code matched by the low two bytes of `word`, or [#NO_CODE] if none.
7491
///
7592
/// @param word an input word; only its low 16 bits (first two input bytes) are consulted
7693
/// @return the matched symbol code, or [#NO_CODE] when there is no length-1 or length-2 match
7794
int codeFor(long word) {
78-
int packed = slots[(int) (word & 0xFFFF)];
79-
return packed == 0 ? NO_CODE : packed >>> 8;
95+
return packedFor(word) >> 8;
8096
}
8197

8298
/// Returns the length of the symbol matched by the low two bytes of `word`: 2, 1, or 0 for no
83-
/// match. One array read, no branching.
99+
/// match.
84100
///
85101
/// @param word an input word; only its low 16 bits (first two input bytes) are consulted
86102
/// @return the matched symbol length in bytes, or 0 when there is no match
87103
int lengthFor(long word) {
88-
return length(slots[(int) (word & 0xFFFF)]);
104+
return length(packedFor(word));
89105
}
90106

91107
private static int length(int packed) {

0 commit comments

Comments
 (0)