Skip to content

Commit 8d778ba

Browse files
dfa1claude
andcommitted
feat(fsst): branch-free matching (ShortCodeTable + LossyPerfectHashTable + Matcher)
Implements the FSST paper's Algorithm 4 ("lossy perfect hashing", §5.1) as the matching core for the extracted fsst module (issue #287, PR 2 of the plan in goofy-roaming-moler.md). WHY: the current writer's FsstEncodingEncoder.SymbolTable.longestMatch probes symbol lengths 8 down to 1 in a per-position loop — up to eight sequential open-addressing hash lookups per input byte. That variable-length loop is exactly the non-uniform hot-loop body CLAUDE.md forbids (it blocks C2 auto-vectorization). This replaces it with an O(1) branch-free structure: - ShortCodeTable: a 65536-entry array direct-indexed on the first two input bytes, resolving 0/1/2-byte matches in one array read. - LossyPerfectHashTable: 2048 slots (matching the spiraldb/fsst Rust reference's L1D-cache-line-split reasoning and the vortex-jni benchmark target, not the paper's literal 4096), keyed on the first three bytes, resolving 3-8 byte matches with one hash lookup plus one masked 8-byte compare — no probing. - Matcher: composes the two branch-free, no loop over candidate lengths. Collision-ordering invariant (load-bearing): the hash table inserts in a single forward pass with first-writer-wins. The caller must pass symbols in descending-gain order, so on a 3-byte-prefix hash collision the higher-gain symbol (seen first) keeps the slot and the lower-gain one is rejected, never overwritten — the paper's rule that the more valuable symbol wins a lossy collision. These classes do not re-sort their input. Tests cover a real collision (higher gain wins, lower gain correctly misses) and an adversarial hash-collision-but-byte-mismatch case proving the masked compare, not the hash alone, gates a hit. Purely additive: writer/reader untouched; these are wired into training in PR 3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2b8db4f commit 8d778ba

6 files changed

Lines changed: 657 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package io.github.dfa1.vortex.fsst;
2+
3+
import java.util.List;
4+
5+
/// Lossy perfect hash table resolving 3-8 byte FSST matches from the first three bytes of an input
6+
/// word (the FSST paper's Algorithm 4, §5.1 "Predicated Scalar Compression").
7+
///
8+
/// Each slot holds at most one candidate symbol. A lookup hashes the input word's first three bytes
9+
/// to exactly one slot, reads it (no probing, no chaining), and confirms the match with a single
10+
/// masked 8-byte compare: the input word's high bits beyond the candidate's length are masked off,
11+
/// then compared against the candidate's packed bytes. A slot miss or a failed compare is a "no
12+
/// match" — this is what "lossy" means: a genuine 3-8 byte match can occasionally miss because a
13+
/// higher-gain symbol won its slot on a hash collision. That is harmless, because greedy parsing
14+
/// then falls back to a shorter match ([ShortCodeTable]) or an escape — never to wrong output.
15+
final class LossyPerfectHashTable {
16+
17+
/// Number of slots. The paper specifies 4096; the well-regarded Rust reference
18+
/// `spiraldb/fsst` uses 2048, citing avoidance of L1D cache-line splits (each slot is small, so
19+
/// 2048 slots keep the whole table within a handful of cache lines that a scan touches
20+
/// repeatedly), and 2048 is this project's benchmark target (`vortex-jni`) — so we deliberately
21+
/// match the reference here rather than the paper's literal 4096. A power of two lets the hash
22+
/// mask instead of taking a modulo, which the hot-loop rule (no per-element modulo/division)
23+
/// requires.
24+
private static final int SLOTS = 2048;
25+
26+
/// Mask selecting a slot index from a hash; valid because [#SLOTS] is a power of two.
27+
private static final int SLOT_MASK = SLOTS - 1;
28+
29+
/// Multiply-xor mixing constant, the same shape as the old encoder's `SymbolTable.indexHash`
30+
/// (the golden-ratio 64-bit odd constant `2^64 / phi`). Multiplying by a large odd constant and
31+
/// folding the high bits down spreads the low three input bytes across the whole word so the
32+
/// slot mask sees well-mixed bits, avoiding clustering when many symbols share a low byte.
33+
private static final long HASH_MULTIPLIER = 0x9E3779B97F4A7C15L;
34+
35+
/// Low three bytes of the input word — the only bytes the hash keys on.
36+
private static final long PREFIX_MASK = 0x00FF_FFFFL;
37+
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;
62+
}
63+
64+
/// Builds the table from the trained symbols in descending-gain order, keeping only those of
65+
/// length 3-8 (shorter symbols are the [ShortCodeTable]'s job and are skipped here). The list
66+
/// index is each symbol's code, matching the parallel-array convention
67+
/// [Decompressor#of(long[], int[])] uses.
68+
///
69+
/// Insertion is a single forward pass with first-writer-wins on collision. WHY this is
70+
/// load-bearing: the caller passes symbols in descending gain order, so when two symbols' first
71+
/// three bytes hash to the same slot, the one seen first (the higher-gain one) keeps the slot
72+
/// and the later (lower-gain) one is skipped, never overwritten. This is exactly the paper's
73+
/// rule that the more valuable symbol wins a lossy collision. This class must NOT re-sort its
74+
/// input — it consumes whatever order the caller gives and inserts once.
75+
///
76+
/// @param symbolsByGainDescending the trained symbols, code = list index, gain-descending
77+
/// @return a hash table resolving 3-8 byte matches with first-writer-wins on collision
78+
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];
84+
for (int code = 0; code < symbolsByGainDescending.size(); code++) {
85+
Symbol symbol = symbolsByGainDescending.get(code);
86+
if (symbol.length() < 3) {
87+
continue; // Length 1-2 belongs to ShortCodeTable.
88+
}
89+
int slot = slotFor(symbol.packedBytes());
90+
if (occupied[slot]) {
91+
continue; // First writer (higher gain) wins; skip the collision.
92+
}
93+
occupied[slot] = true;
94+
packedBytes[slot] = symbol.packedBytes();
95+
keepMask[slot] = keepMaskFor(symbol.length());
96+
lengths[slot] = symbol.length();
97+
codes[slot] = code;
98+
}
99+
return new LossyPerfectHashTable(packedBytes, keepMask, lengths, codes, occupied);
100+
}
101+
102+
/// Looks up `word` and reports whether a stored 3-8 byte symbol really matches it.
103+
///
104+
/// 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].
108+
///
109+
/// @param word an 8-byte little-endian input word starting at the current match position, with
110+
/// 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]);
116+
}
117+
118+
/// Computes the slot index a word hashes to, keyed on its first three bytes. Package-visible so
119+
/// tests can construct deliberate hash collisions against a stable, inspectable hash rather than
120+
/// blindly brute-forcing one — a stable hash is easier to reason about and to test.
121+
///
122+
/// @param word an input word; only its low three bytes are hashed
123+
/// @return the slot index in `0 .. SLOTS - 1`
124+
static int slotFor(long word) {
125+
long mixed = (word & PREFIX_MASK) * HASH_MULTIPLIER;
126+
return (int) (mixed >>> 32) & SLOT_MASK;
127+
}
128+
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+
}
143+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.github.dfa1.vortex.fsst;
2+
3+
import java.util.List;
4+
5+
/// Branch-free longest-match matcher composing a [LossyPerfectHashTable] (3-8 byte candidates) with
6+
/// a [ShortCodeTable] (0/1/2 byte candidates), the FSST paper's Algorithm 4.
7+
///
8+
/// This replaces the old encoder's `longestMatch`, which probed symbol lengths 8 down to 1 in a
9+
/// per-position loop (up to eight sequential hash lookups per input byte). Here a single hash-table
10+
/// lookup plus one masked compare yields the 3-8 byte candidate, a single array read yields the
11+
/// 0/1/2 byte candidate, and one conditional select picks the longer — no loop over candidate
12+
/// lengths, no per-length branch. That uniform body is exactly what the hot-loop rule (no
13+
/// modulo/division/variable-target branch per element) needs to stay JIT-vectorizable, which the
14+
/// old eight-iteration loop could not deliver.
15+
public final class Matcher {
16+
17+
/// Longest symbol length that can be resolved, in bytes.
18+
private static final int MAX_SYMBOL_LENGTH = 8;
19+
20+
private final LossyPerfectHashTable hashTable;
21+
private final ShortCodeTable shortCodes;
22+
23+
private Matcher(LossyPerfectHashTable hashTable, ShortCodeTable shortCodes) {
24+
this.hashTable = hashTable;
25+
this.shortCodes = shortCodes;
26+
}
27+
28+
/// Builds a matcher from the trained symbols in descending-gain order.
29+
///
30+
/// The list index is each symbol's code, matching the parallel-array convention
31+
/// [Decompressor#of(long[], int[])] uses. The whole list is handed to both tables: each keeps
32+
/// only the entries of its own length class ([ShortCodeTable] takes lengths 1-2,
33+
/// [LossyPerfectHashTable] takes lengths 3-8), so a symbol's code stays equal to its index in
34+
/// the original list without any placeholder padding. The caller's gain-descending order is
35+
/// preserved, since it is load-bearing for the hash table's first-writer-wins collision
36+
/// handling. The input is not re-sorted.
37+
///
38+
/// @param symbolsByGainDescending the trained symbols, code = list index, gain-descending
39+
/// @return a matcher resolving the longest 0-8 byte match at any input position
40+
public static Matcher of(List<Symbol> symbolsByGainDescending) {
41+
return new Matcher(
42+
LossyPerfectHashTable.of(symbolsByGainDescending),
43+
ShortCodeTable.of(symbolsByGainDescending));
44+
}
45+
46+
/// Returns the longest match at the current input position packed as `code << 8 | length`.
47+
///
48+
/// The hash table is consulted first for a 3-8 byte candidate; on a real hit that candidate is
49+
/// returned, otherwise the result falls back to the short-code table's 0/1/2 byte answer. A
50+
/// length of 0 (and code [ShortCodeTable#NO_CODE]) means no symbol matched and the caller must
51+
/// escape the current byte. Callers that want the parts separately can use
52+
/// [#codeOf(int)] and [#lengthOf(int)].
53+
///
54+
/// @param word an 8-byte little-endian input word starting at the current match position, with
55+
/// any bytes past the remaining input already zero-padded by the caller
56+
/// @return the longest match as `code << 8 | length`; length 0 signals "no match, escape"
57+
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);
63+
}
64+
65+
/// Extracts the code from a packed [#longestMatch(long)] result.
66+
///
67+
/// @param packedMatch a `code << 8 | length` value from [#longestMatch(long)]
68+
/// @return the matched symbol code, or [ShortCodeTable#NO_CODE] when the length is 0
69+
public static int codeOf(int packedMatch) {
70+
return packedMatch >> 8;
71+
}
72+
73+
/// Extracts the length from a packed [#longestMatch(long)] result.
74+
///
75+
/// @param packedMatch a `code << 8 | length` value from [#longestMatch(long)]
76+
/// @return the matched symbol length in bytes, 1-8, or 0 when there is no match
77+
public static int lengthOf(int packedMatch) {
78+
return packedMatch & 0xFF;
79+
}
80+
81+
/// Longest symbol length resolvable by this matcher, in bytes.
82+
///
83+
/// @return the maximum symbol length, 8
84+
public static int maxSymbolLength() {
85+
return MAX_SYMBOL_LENGTH;
86+
}
87+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package io.github.dfa1.vortex.fsst;
2+
3+
import java.util.List;
4+
5+
/// Direct-indexed table resolving the shortest FSST matches — length 0 (no match), 1, or 2 —
6+
/// from the first two bytes of an input word (the FSST paper's Algorithm 4 `shortCodes`).
7+
///
8+
/// The key is the input word's low 16 bits read as an unsigned little-endian value, i.e. the first
9+
/// two bytes of the input at the current position (byte 0 in the low 8 bits, byte 1 next). That key
10+
/// indexes one of 65536 slots, each holding a packed `code << 8 | length` giving the matched symbol
11+
/// code and its length in one array read — no hashing, no probing.
12+
///
13+
/// Only length-1 and length-2 symbols populate the table; longer symbols are the
14+
/// [LossyPerfectHashTable]'s job. Each 16-bit key `hi:lo` is seeded so that, absent a longer match,
15+
/// it resolves to the length-1 symbol for its low byte `lo` (if any). A length-2 symbol then
16+
/// overwrites the specific `hi:lo` slot for its exact two bytes, taking precedence over the
17+
/// length-1 fallback. A key whose low byte has no length-1 symbol and no length-2 symbol resolves
18+
/// to [#NO_CODE] with length 0, telling the caller to escape that single byte.
19+
final class ShortCodeTable {
20+
21+
/// Number of slots — one per possible 16-bit (two-byte) key.
22+
private static final int SLOTS = 1 << 16;
23+
24+
/// Sentinel code meaning "no symbol matched"; the caller must escape the current byte. Real
25+
/// codes are `0..254` (`0xFF` is the escape), so `-1` can never collide with a real code.
26+
static final int NO_CODE = -1;
27+
28+
/// Packed `code << 8 | length` per 16-bit key. A zero length marks "no match".
29+
private final int[] slots;
30+
31+
private ShortCodeTable(int[] slots) {
32+
this.slots = slots;
33+
}
34+
35+
/// Builds the table from symbols in descending-gain order, keeping only the length-1 and
36+
/// length-2 entries. The list index is each symbol's code, matching the parallel-array
37+
/// convention [Decompressor#of(long[], int[])] uses (array index is the code).
38+
///
39+
/// Length-1 symbols are seeded first across all 256 high-byte keys that share their low byte,
40+
/// so any two-byte prefix falls back to its low byte's single-byte code; length-2 symbols then
41+
/// overwrite their exact key, taking precedence. Input order beyond that does not matter here:
42+
/// a length-2 symbol owns a unique key, so there is no gain-order contention within this table
43+
/// (unlike [LossyPerfectHashTable], where collisions make insertion order load-bearing).
44+
///
45+
/// @param symbolsByGainDescending the trained symbols, code = list index, gain-descending
46+
/// @return a table resolving 0/1/2-byte matches for any two-byte input prefix
47+
static ShortCodeTable of(List<Symbol> symbolsByGainDescending) {
48+
int[] slots = new int[SLOTS];
49+
for (int code = 0; code < symbolsByGainDescending.size(); code++) {
50+
Symbol symbol = symbolsByGainDescending.get(code);
51+
if (symbol.length() == 1) {
52+
int low = symbol.byteAt(0) & 0xFF;
53+
int packed = code << 8 | 1;
54+
for (int high = 0; high < 256; high++) {
55+
int key = high << 8 | low;
56+
if (length(slots[key]) == 0) {
57+
slots[key] = packed;
58+
}
59+
}
60+
}
61+
}
62+
for (int code = 0; code < symbolsByGainDescending.size(); code++) {
63+
Symbol symbol = symbolsByGainDescending.get(code);
64+
if (symbol.length() == 2) {
65+
int key = (int) (symbol.packedBytes() & 0xFFFF);
66+
slots[key] = code << 8 | 2;
67+
}
68+
}
69+
return new ShortCodeTable(slots);
70+
}
71+
72+
/// Returns the code matched by the low two bytes of `word`, or [#NO_CODE] if none. One array
73+
/// read, no branching.
74+
///
75+
/// @param word an input word; only its low 16 bits (first two input bytes) are consulted
76+
/// @return the matched symbol code, or [#NO_CODE] when there is no length-1 or length-2 match
77+
int codeFor(long word) {
78+
int packed = slots[(int) (word & 0xFFFF)];
79+
return packed == 0 ? NO_CODE : packed >>> 8;
80+
}
81+
82+
/// 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.
84+
///
85+
/// @param word an input word; only its low 16 bits (first two input bytes) are consulted
86+
/// @return the matched symbol length in bytes, or 0 when there is no match
87+
int lengthFor(long word) {
88+
return length(slots[(int) (word & 0xFFFF)]);
89+
}
90+
91+
private static int length(int packed) {
92+
return packed & 0xFF;
93+
}
94+
}

0 commit comments

Comments
 (0)