Skip to content

Commit 1b9714c

Browse files
dfa1claude
andcommitted
feat(fsst): rewire writer/reader adapters onto the fsst module
PR 5 of #287: reduce the two FSST production files to thin wire adapters over the standalone vortex-fsst module. Only production code touched; no test files changed (that is PR 6's scope). The wire format is unchanged. Deleted (writer): the ~250-line inline SymbolTable/SymbolCandidate machinery — training loop (trainSymbolTable), stratified sampling (sampleRowIndices), the open-addressing longest-match index, candidate counting, and per-row compress. All of that now lives in CompressorBuilder/Compressor/Matcher. Deleted (reader): the byte-by-byte decompressString loop. Decoding now goes through Decompressor (the unconditional 8-byte-store Algorithm 1). Kept (both): all wire/metadata plumbing — UTF-8 conversion, ProtoFSSTMetadata with narrowestUnsigned ptype selection, the EncodeNode/EncodeResult two-child tree (uncompLens/codesOff), arena allocation of every output buffer, the readUnsigned ptype dispatch, and the VarBinArray.OffsetMode result. The TRAINING_SAMPLE_SEED reproducibility constant is retained and now feeds CompressorBuilder.seed(). Wire-order code remapping (the subtle part — over-explained on purpose): The Compressor numbers its codes in gain-descending order, but the vortex.fsst wire format lays symbols out in length order (all multi-byte length-ascending, then length-1 last; mirrors Rust FSSTData::validate_symbol_lengths). So the writer must express BOTH the symbol table AND the code stream in wire codes. wireOrder = compressor.codesSortedByLength() — wireOrder[i] is the internal (gain-descending) code that belongs at wire position i. internalToWire[wireOrder[i]] = i — the inverse permutation, mapping every internal code the compressor emits to its wire position. Symbol table buffers: at wire position i write packedSymbol(wireOrder[i]) and symbolLength(wireOrder[i]) — the symbol living at wire slot i is internal symbol wireOrder[i]. Code stream: compressor.compress emits internal codes; each non-escape code byte c is rewritten to internalToWire[c]. The escape byte 0xFF and the single literal byte following it are copied through untouched — the literal is raw data, not a code, and 0xFF never collides with a code since codes are 0..254 (numSymbols <= 255). Reader side: it reads the wire symbol table into code-indexed arrays and a code b in the stream indexes wire slot b directly, so encoder position i and decoder index i refer to the same symbol — the file is self-consistent and round-trips. Verified: (1) writer,reader unit suite green with zero test changes (1713 tests); (2) checkstyle/javadoc gate green; (3) JavaWritesRustReadsIntegration javaWriter_jniReader_fsstUtf8Column — the real Rust reference reader decodes this encoder's output; (4) RustJavaReaderComparison fsst.vortex fixture (our decoder reads a Rust-written FSST file) and FileSizeComparison highCardinalityUtf8_javaVsJni (FSST still dispatch-selected) green; (5) full reactor build green; plus a throwaway 11-row driver proving encode→remap→wire symbol table→Decompressor reproduces every input byte-for-byte with the wire table in valid length order. Also fixed the empty-table read: with 0 trained symbols the writer floors the symbol buffers to a 1-byte placeholder, so the decoder derives the symbol count from symbolsBuf.byteSize()/8 (floors to 0) rather than symbolLensBuf.byteSize(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 76e26eb commit 1b9714c

4 files changed

Lines changed: 92 additions & 353 deletions

File tree

reader/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
<groupId>io.github.dfa1.vortex</groupId>
2121
<artifactId>vortex-core</artifactId>
2222
</dependency>
23+
<dependency>
24+
<groupId>io.github.dfa1.vortex</groupId>
25+
<artifactId>vortex-fsst</artifactId>
26+
</dependency>
2327
<dependency>
2428
<groupId>io.github.dfa1.zstd</groupId>
2529
<artifactId>zstd</artifactId>

reader/src/main/java/io/github/dfa1/vortex/reader/decode/FsstEncodingDecoder.java

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
import io.github.dfa1.vortex.core.io.VortexFormat;
1010
import io.github.dfa1.vortex.core.proto.ProtoFSSTMetadata;
1111
import io.github.dfa1.vortex.core.proto.ProtoPType;
12+
import io.github.dfa1.vortex.fsst.Decompressor;
1213

1314
import java.io.IOException;
1415
import java.lang.foreign.MemorySegment;
1516
import java.lang.foreign.ValueLayout;
1617

1718
/// Read-only decoder for `vortex.fsst`.
19+
///
20+
/// This class is a thin wire adapter over the standalone `vortex-fsst` module (issue #287): after
21+
/// parsing the `vortex.fsst` wire buffers (symbol table, per-row uncompressed lengths, code offsets,
22+
/// [ProtoFSSTMetadata]), it hands the symbol table and each row's code range to a [Decompressor],
23+
/// which runs the FSST paper's Algorithm 1 decode.
1824
public final class FsstEncodingDecoder implements EncodingDecoder {
1925

20-
private static final int ESCAPE = 0xFF;
21-
2226
@Override
2327
public EncodingId encodingId() {
2428
return EncodingId.VORTEX_FSST;
@@ -63,44 +67,43 @@ public Array decode(DecodeContext ctx) {
6367
long uncompLensCap = SegmentBroadcast.capacity(uncompLensSeg, uncompLenPType.byteSize());
6468
long codesOffCap = SegmentBroadcast.capacity(codesOffsetsSeg, codesOffPType.byteSize());
6569

70+
// Read the wire symbol table into parallel code-indexed arrays once per chunk (there are at
71+
// most 255 symbols), then hand them to the decompressor. symbolsBuf carries one LSB-first
72+
// long per symbol, so its size divided by 8 is the symbol count: an empty table is written
73+
// as a 1-byte placeholder buffer (allocations are floored at 1 byte), which floors to 0
74+
// symbols here — an all-escape column decodes without touching the symbol table.
75+
int numSymbols = (int) (symbolsBuf.byteSize() / 8);
76+
long[] packedSymbols = new long[numSymbols];
77+
int[] symbolLengths = new int[numSymbols];
78+
for (int code = 0; code < numSymbols; code++) {
79+
packedSymbols[code] = symbolsBuf.getAtIndex(VortexFormat.LE_LONG, code);
80+
symbolLengths[code] = Byte.toUnsignedInt(symbolLensBuf.get(ValueLayout.JAVA_BYTE, code));
81+
}
82+
Decompressor decompressor = Decompressor.of(packedSymbols, symbolLengths);
83+
6684
long totalUncompressed = 0L;
6785
for (long i = 0; i < n; i++) {
6886
totalUncompressed += readUnsigned(uncompLensSeg, i % uncompLensCap, uncompLenPType);
6987
}
7088

71-
MemorySegment outBytes = ctx.arena().allocate(totalUncompressed);
89+
// Allocate 7 bytes of slack past the true logical length: the decompressor's unconditional
90+
// 8-byte-store trick writes a full 8 bytes for the final symbol even when it contributes as
91+
// few as 1 real byte. The slack is sliced off before the buffer is exposed, so callers still
92+
// see an exactly-sized buffer.
93+
MemorySegment outBytes = ctx.arena().allocate(totalUncompressed + 7);
7294
MemorySegment outOffsets = ctx.arena().allocate((n + 1) * 4L, 4);
7395
outOffsets.setAtIndex(VortexFormat.LE_INT, 0, 0);
7496

7597
long outPos = 0L;
7698
for (long i = 0; i < n; i++) {
7799
long cStart = readUnsigned(codesOffsetsSeg, i % codesOffCap, codesOffPType);
78100
long cEnd = readUnsigned(codesOffsetsSeg, (i + 1) % codesOffCap, codesOffPType);
79-
outPos = decompressString(compressedBytes, symbolsBuf, symbolLensBuf,
80-
cStart, cEnd, outBytes, outPos);
101+
outPos = decompressor.decompress(compressedBytes, cStart, cEnd, outBytes, outPos);
81102
outOffsets.setAtIndex(VortexFormat.LE_INT, i + 1, (int) outPos);
82103
}
83104

84-
return new VarBinArray.OffsetMode(ctx.dtype(), n, outBytes.asReadOnly(), outOffsets.asReadOnly(), PType.I32);
85-
}
86-
87-
private static long decompressString(
88-
MemorySegment compressed, MemorySegment symbols, MemorySegment symLens,
89-
long start, long end, MemorySegment out, long outPos
90-
) {
91-
for (long j = start; j < end; j++) {
92-
int b = Byte.toUnsignedInt(compressed.get(ValueLayout.JAVA_BYTE, j));
93-
if (b == ESCAPE) {
94-
out.set(ValueLayout.JAVA_BYTE, outPos++, compressed.get(ValueLayout.JAVA_BYTE, ++j));
95-
} else {
96-
int symLen = Byte.toUnsignedInt(symLens.get(ValueLayout.JAVA_BYTE, b));
97-
long sym = symbols.getAtIndex(VortexFormat.LE_LONG, b);
98-
for (int k = 0; k < symLen; k++) {
99-
out.set(ValueLayout.JAVA_BYTE, outPos++, (byte) (sym >>> (k * 8)));
100-
}
101-
}
102-
}
103-
return outPos;
105+
return new VarBinArray.OffsetMode(ctx.dtype(), n,
106+
outBytes.asSlice(0, totalUncompressed).asReadOnly(), outOffsets.asReadOnly(), PType.I32);
104107
}
105108

106109
private static long readUnsigned(MemorySegment seg, long idx, PType ptype) {

writer/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
<groupId>io.github.dfa1.vortex</groupId>
2121
<artifactId>vortex-core</artifactId>
2222
</dependency>
23+
<dependency>
24+
<groupId>io.github.dfa1.vortex</groupId>
25+
<artifactId>vortex-fsst</artifactId>
26+
</dependency>
2327
<dependency>
2428
<groupId>io.github.dfa1.zstd</groupId>
2529
<artifactId>zstd</artifactId>

0 commit comments

Comments
 (0)