Skip to content

Commit 347bc34

Browse files
dfa1claude
andcommitted
feat: support vortex.dict layout and vortex.fsst codec
Adds full read support for two encodings Rust chooses for high-cardinality string columns (e.g. 30 Nasdaq tickers over 10M rows): - FsstCodec: decompresses Fast Static Symbol Table–encoded strings; 3-buffer wire format (symbol table, lengths, compressed bytes) + 2 children (uncompressed lengths, codes offsets) - DictLayoutMetadata proto: layout-level dict (codes_ptype at tag 1) - decodeDictLayout: decodes values (FSST) + codes (bitpacked, possibly chunked), then expands via expandDictStrings - decodeConcatPrimitive: handles chunked codes layout by decoding each flat and concatenating into one buffer before dict expansion - RustVsJavaReadBenchmark: 30 real Nasdaq tickers with per-ticker price state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6e8ba5d commit 347bc34

7 files changed

Lines changed: 321 additions & 10 deletions

File tree

core/src/main/java/io/github/dfa1/vortex/core/Layout.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ public boolean isStruct() {
3737
public boolean isZoned() {
3838
return ZONED.equals(encodingId);
3939
}
40+
41+
public boolean isDict() {
42+
return DICT.equals(encodingId);
43+
}
4044
}

core/src/main/java/io/github/dfa1/vortex/encoding/CodecId.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public enum CodecId {
1717
VORTEX_ALP("vortex.alp"),
1818
VORTEX_BITPACKED("vortex.bitpacked"),
1919
VORTEX_VARBIN("vortex.varbin"),
20+
VORTEX_FSST("vortex.fsst"),
2021
VORTEX_NULL("vortex.null"),
2122

2223
// Layout encoding IDs included so parser/registry can represent them safely
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package io.github.dfa1.vortex.encoding;
2+
3+
import com.google.protobuf.InvalidProtocolBufferException;
4+
import dev.vortex.proto.EncodingProtos;
5+
import io.github.dfa1.vortex.core.Array;
6+
import io.github.dfa1.vortex.core.ArrayStats;
7+
import io.github.dfa1.vortex.core.DType;
8+
import io.github.dfa1.vortex.core.PType;
9+
10+
import java.lang.foreign.MemorySegment;
11+
import java.lang.foreign.ValueLayout;
12+
import java.nio.ByteBuffer;
13+
import java.nio.ByteOrder;
14+
15+
/// Decoder for {@code vortex.fsst} — Fast Static Symbol Table string compression.
16+
///
17+
/// <p>Wire format (3-buffer current format):
18+
/// <ul>
19+
/// <li>Buffer 0: symbol table — up to 256 entries, each 8 bytes LE (Symbol = u64)</li>
20+
/// <li>Buffer 1: symbol_lengths — 1 byte per symbol, actual byte count in the symbol</li>
21+
/// <li>Buffer 2: compressed code bytes for all strings concatenated</li>
22+
/// <li>Child 0: uncompressed_lengths — primitive array (I32/I64) of original string lengths</li>
23+
/// <li>Child 1: codes_offsets — primitive array (I32/I64) of offsets into buf[2], length = n+1</li>
24+
/// </ul>
25+
///
26+
/// <p>Output: varbin-compatible {@code Array} (buffer[0] = uncompressed bytes, child[0] = I32 offsets).
27+
public final class FsstCodec implements Codec {
28+
29+
private static final int ESCAPE = 0xFF;
30+
31+
private static final ValueLayout.OfLong LE_LONG = ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
32+
private static final ValueLayout.OfInt LE_INT = ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
33+
34+
@Override
35+
public CodecId encodingId() {
36+
return CodecId.VORTEX_FSST;
37+
}
38+
39+
@Override
40+
public EncodeResult encode(DType dtype, Object data) {
41+
throw new UnsupportedOperationException("encode not supported by " + encodingId());
42+
}
43+
44+
@Override
45+
public Array decode(DecodeContext ctx) {
46+
ByteBuffer rawMeta = ctx.metadata();
47+
if (rawMeta == null) {
48+
throw new IllegalStateException("vortex.fsst: missing metadata");
49+
}
50+
EncodingProtos.FSSTMetadata meta;
51+
try {
52+
meta = EncodingProtos.FSSTMetadata.parseFrom(rawMeta.duplicate());
53+
} catch (InvalidProtocolBufferException e) {
54+
throw new IllegalStateException("vortex.fsst: invalid metadata", e);
55+
}
56+
57+
PType uncompLenPType = PType.values()[meta.getUncompressedLengthsPtype().getNumber()];
58+
PType codesOffPType = PType.values()[meta.getCodesOffsetsPtype().getNumber()];
59+
60+
long n = ctx.rowCount();
61+
62+
MemorySegment symbolsBuf = ctx.buffer(0); // 8 bytes per symbol (LE u64)
63+
MemorySegment symbolLensBuf = ctx.buffer(1); // 1 byte per symbol
64+
MemorySegment compressedBytes = ctx.buffer(2); // FSST-compressed heap
65+
66+
// Decode child arrays
67+
Array uncompLens = decodeChild(ctx, 0, uncompLenPType, n);
68+
Array codesOffsets = decodeChild(ctx, 1, codesOffPType, n + 1);
69+
MemorySegment uncompLensSeg = uncompLens.buffer(0);
70+
MemorySegment codesOffsetsSeg = codesOffsets.buffer(0);
71+
72+
// Total uncompressed size (sum of uncompressed_lengths)
73+
long totalUncompressed = 0L;
74+
for (long i = 0; i < n; i++) {
75+
totalUncompressed += readUnsigned(uncompLensSeg, i, uncompLenPType);
76+
}
77+
78+
MemorySegment outBytes = ctx.arena().allocate(totalUncompressed);
79+
MemorySegment outOffsets = ctx.arena().allocate((n + 1) * 4L, 4);
80+
outOffsets.setAtIndex(LE_INT, 0, 0);
81+
82+
long outPos = 0L;
83+
for (long i = 0; i < n; i++) {
84+
long cStart = readUnsigned(codesOffsetsSeg, i, codesOffPType);
85+
long cEnd = readUnsigned(codesOffsetsSeg, i + 1, codesOffPType);
86+
outPos = decompressString(compressedBytes, symbolsBuf, symbolLensBuf,
87+
cStart, cEnd, outBytes, outPos);
88+
outOffsets.setAtIndex(LE_INT, i + 1, (int) outPos);
89+
}
90+
91+
Array offsets = new Array(new DType.Primitive(PType.I32, false), n + 1,
92+
new MemorySegment[]{outOffsets.asReadOnly()}, Array.NO_CHILDREN, ArrayStats.empty());
93+
return new Array(ctx.dtype(), n,
94+
new MemorySegment[]{outBytes.asReadOnly()},
95+
new Array[]{offsets},
96+
ArrayStats.empty());
97+
}
98+
99+
// ── Helpers ───────────────────────────────────────────────────────────────
100+
101+
private Array decodeChild(DecodeContext parent, int idx, PType ptype, long rowCount) {
102+
ArrayNode childNode = parent.node().children()[idx];
103+
DType dtype = new DType.Primitive(ptype, false);
104+
DecodeContext childCtx = new DecodeContext(
105+
childNode, dtype, rowCount,
106+
parent.segmentBuffers(), parent.registry(), parent.arena());
107+
return parent.registry().decode(childCtx);
108+
}
109+
110+
private static long decompressString(
111+
MemorySegment compressed, MemorySegment symbols, MemorySegment symLens,
112+
long start, long end, MemorySegment out, long outPos
113+
) {
114+
for (long j = start; j < end; j++) {
115+
int b = Byte.toUnsignedInt(compressed.get(ValueLayout.JAVA_BYTE, j));
116+
if (b == ESCAPE) {
117+
// next byte is literal
118+
out.set(ValueLayout.JAVA_BYTE, outPos++, compressed.get(ValueLayout.JAVA_BYTE, ++j));
119+
} else {
120+
int symLen = Byte.toUnsignedInt(symLens.get(ValueLayout.JAVA_BYTE, b));
121+
// symbols[b] is 8 bytes LE; emit first symLen bytes
122+
long sym = symbols.getAtIndex(LE_LONG, b);
123+
for (int k = 0; k < symLen; k++) {
124+
out.set(ValueLayout.JAVA_BYTE, outPos++, (byte) (sym >>> (k * 8)));
125+
}
126+
}
127+
}
128+
return outPos;
129+
}
130+
131+
private static long readUnsigned(MemorySegment seg, long idx, PType ptype) {
132+
return switch (ptype) {
133+
case U8 -> Byte.toUnsignedLong(seg.get(ValueLayout.JAVA_BYTE, idx));
134+
case U16 -> Short.toUnsignedLong(seg.get(
135+
ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), idx * 2));
136+
case U32 -> Integer.toUnsignedLong(seg.getAtIndex(LE_INT, idx));
137+
case I32 -> seg.getAtIndex(LE_INT, idx);
138+
case I64, U64 -> seg.getAtIndex(LE_LONG, idx);
139+
default -> throw new IllegalArgumentException("vortex.fsst: unsupported ptype " + ptype);
140+
};
141+
}
142+
}

core/src/main/proto/encodings.proto

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,15 @@ message SequenceMetadata {
5454
message VarBinMetadata {
5555
vortex.dtype.PType offsets_ptype = 1;
5656
}
57+
58+
message FSSTMetadata {
59+
vortex.dtype.PType uncompressed_lengths_ptype = 1;
60+
vortex.dtype.PType codes_offsets_ptype = 2;
61+
}
62+
63+
// Layout-level dict metadata (different field ordering from codec DictMetadata).
64+
message DictLayoutMetadata {
65+
vortex.dtype.PType codes_ptype = 1;
66+
optional bool is_nullable_codes = 2;
67+
optional bool all_values_referenced = 3;
68+
}

core/src/main/resources/META-INF/services/io.github.dfa1.vortex.encoding.Codec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ io.github.dfa1.vortex.encoding.AlpCodec
1010
io.github.dfa1.vortex.encoding.RunEndCodec
1111
io.github.dfa1.vortex.encoding.ConstantCodec
1212
io.github.dfa1.vortex.encoding.VarBinCodec
13+
io.github.dfa1.vortex.encoding.FsstCodec

performance/src/main/java/io/github/dfa1/vortex/performance/RustVsJavaReadBenchmark.java

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@
4242

4343
import java.io.IOException;
4444
import java.lang.foreign.MemorySegment;
45+
import java.util.Arrays;
4546
import java.lang.foreign.ValueLayout;
4647
import java.nio.charset.StandardCharsets;
4748
import java.nio.file.Files;
4849
import java.nio.file.Path;
4950
import java.time.LocalDate;
50-
import java.util.ArrayList;
5151
import java.util.HashMap;
5252
import java.util.List;
5353
import java.util.Random;
@@ -276,11 +276,25 @@ public long javaReadSymbol() throws IOException {
276276
return sum;
277277
}
278278

279+
// Real Nasdaq tickers — mix of lengths (1–5 chars) for realistic varbin distribution.
280+
private static final String[] NASDAQ_TICKERS = {
281+
"AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "TSLA", "AVGO", "COST", "NFLX",
282+
"AMD", "ADBE", "QCOM", "PEP", "CSCO", "TXN", "INTC", "CMCSA", "INTU", "AMGN",
283+
"HON", "AMAT", "MU", "LRCX", "KLAC", "MRVL", "PANW", "SNPS", "CDNS", "REGN"
284+
};
285+
private static final byte[][] TICKER_BYTES;
286+
287+
static {
288+
TICKER_BYTES = new byte[NASDAQ_TICKERS.length][];
289+
for (int i = 0; i < NASDAQ_TICKERS.length; i++) {
290+
TICKER_BYTES[i] = NASDAQ_TICKERS[i].getBytes(StandardCharsets.UTF_8);
291+
}
292+
}
293+
279294
private void writeJni(Path path) throws IOException {
280295
String uri = path.toAbsolutePath().toUri().toString();
281296
try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create(
282297
SESSION, uri, JNI_SCHEMA, new HashMap<>(), allocator)) {
283-
var batch = new ArrayList<double[]>(BATCH_SIZE);
284298
// reuse arrays — filled per-batch
285299
int[] epochDays = new int[BATCH_SIZE];
286300
byte[][] symbols = new byte[BATCH_SIZE][];
@@ -290,29 +304,35 @@ private void writeJni(Path path) throws IOException {
290304
double[] close = new double[BATCH_SIZE];
291305
long[] volume = new long[BATCH_SIZE];
292306

307+
// Per-ticker price state so each symbol has independent price evolution.
308+
double[] prices = new double[NASDAQ_TICKERS.length];
309+
Arrays.fill(prices, 100.0);
310+
293311
var rng = new Random(42L);
294-
double px = 100.0;
295312
int day = (int) LocalDate.of(2020, 1, 2).toEpochDay();
296313
int rowsLeft = TOTAL_ROWS;
297314

298315
while (rowsLeft > 0) {
299316
int n = Math.min(rowsLeft, BATCH_SIZE);
300317
for (int i = 0; i < n; i++) {
318+
int ticker = i % NASDAQ_TICKERS.length;
319+
double px = prices[ticker];
301320
double ret = rng.nextGaussian() * 0.02;
302321
double o = round(px * (1 + ret * 0.3));
303322
double c = round(px * (1 + ret));
304323
double rng2 = Math.abs(px * rng.nextDouble() * 0.03);
305324
double h = round(Math.max(o, c) + rng2);
306325
double l = round(Math.min(o, c) - rng2);
307-
epochDays[i] = day++;
308-
symbols[i] = "ACME".getBytes(StandardCharsets.UTF_8);
326+
epochDays[i] = day + i / NASDAQ_TICKERS.length;
327+
symbols[i] = TICKER_BYTES[ticker];
309328
open[i] = o;
310329
high[i] = h;
311330
low[i] = l;
312331
close[i] = c;
313332
volume[i] = Math.max(100_000L, Math.round(1_000_000 + rng.nextGaussian() * 200_000));
314-
px = c;
333+
prices[ticker] = c;
315334
}
335+
day += n / NASDAQ_TICKERS.length;
316336
flushJni(writer, epochDays, symbols, open, high, low, close, volume, n);
317337
rowsLeft -= n;
318338
}

0 commit comments

Comments
 (0)