Skip to content

Commit 1003a67

Browse files
dfa1claude
andcommitted
perf(fsst): add JavaVsJniFsstBenchmark baseline
Captures the pre-rewrite baseline for issue #287's FSST rewrite. The benchmark writes/reads a single high-cardinality Utf8 log-line column (1M rows across 20 chunks) that the cost-based dispatch routes through vortex.fsst — @setup asserts via the inspector that vortex.fsst was actually selected, so a future dispatch change can't silently turn this into a no-op measurement of another encoding. It exercises the existing, unmodified vortex.fsst writer/reader adapters (which don't change until PR 5 of the #287 sequence), so re-running the identical benchmark after the rewrite lands shows the "arc" from slow to fast with zero benchmark-code changes. Measured baseline (JDK 25, Apple silicon, Throughput, -f 1, 5 iters): javaFsstEncode 0.085 ± 0.001 ops/s jniFsstEncode 3.024 ± 0.011 ops/s (Java ~36x slower at encode) javaFsstDecode 5.243 ± 1.146 ops/s jniFsstDecode 33.930 ± 0.494 ops/s (Java ~6.5x slower at decode) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8d778ba commit 1003a67

1 file changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package io.github.dfa1.vortex.performance;
2+
3+
import dev.vortex.api.DataSource;
4+
import dev.vortex.api.Expression;
5+
import dev.vortex.api.Partition;
6+
import dev.vortex.api.Scan;
7+
import dev.vortex.api.Session;
8+
import dev.vortex.arrow.ArrowAllocation;
9+
import dev.vortex.jni.NativeLoader;
10+
import io.github.dfa1.vortex.core.error.VortexException;
11+
import io.github.dfa1.vortex.core.model.ColumnName;
12+
import io.github.dfa1.vortex.core.model.DType;
13+
import io.github.dfa1.vortex.inspect.InspectorTree;
14+
import io.github.dfa1.vortex.reader.Chunk;
15+
import io.github.dfa1.vortex.reader.ReadRegistry;
16+
import io.github.dfa1.vortex.reader.VortexReader;
17+
import io.github.dfa1.vortex.reader.array.VarBinArray;
18+
import io.github.dfa1.vortex.writer.VortexWriter;
19+
import io.github.dfa1.vortex.writer.WriteOptions;
20+
import org.apache.arrow.c.ArrowArray;
21+
import org.apache.arrow.c.ArrowSchema;
22+
import org.apache.arrow.c.Data;
23+
import org.apache.arrow.memory.BufferAllocator;
24+
import org.apache.arrow.vector.VarCharVector;
25+
import org.apache.arrow.vector.VariableWidthFieldVector;
26+
import org.apache.arrow.vector.VectorSchemaRoot;
27+
import org.apache.arrow.vector.ipc.ArrowReader;
28+
import org.apache.arrow.vector.types.pojo.ArrowType;
29+
import org.apache.arrow.vector.types.pojo.Field;
30+
import org.apache.arrow.vector.types.pojo.Schema;
31+
import org.openjdk.jmh.annotations.Benchmark;
32+
import org.openjdk.jmh.annotations.BenchmarkMode;
33+
import org.openjdk.jmh.annotations.Fork;
34+
import org.openjdk.jmh.annotations.Level;
35+
import org.openjdk.jmh.annotations.Measurement;
36+
import org.openjdk.jmh.annotations.Mode;
37+
import org.openjdk.jmh.annotations.OutputTimeUnit;
38+
import org.openjdk.jmh.annotations.Scope;
39+
import org.openjdk.jmh.annotations.Setup;
40+
import org.openjdk.jmh.annotations.State;
41+
import org.openjdk.jmh.annotations.TearDown;
42+
import org.openjdk.jmh.annotations.Warmup;
43+
44+
import java.io.IOException;
45+
import java.nio.channels.FileChannel;
46+
import java.nio.charset.StandardCharsets;
47+
import java.nio.file.Files;
48+
import java.nio.file.Path;
49+
import java.nio.file.StandardOpenOption;
50+
import java.util.HashMap;
51+
import java.util.List;
52+
import java.util.Map;
53+
import java.util.Random;
54+
import java.util.concurrent.TimeUnit;
55+
56+
/// FSST benchmark: Java writer/reader vs JNI (Rust) writer/reader on a single
57+
/// high-cardinality Utf8 column that the cost-based dispatch routes through FSST.
58+
///
59+
/// This is the PRE-REWRITE baseline for issue #287's FSST rewrite: it exercises the
60+
/// existing, unmodified `vortex.fsst` writer/reader adapters (which don't change until
61+
/// PR 5 of that sequence), so re-running the identical benchmark after the rewrite lands
62+
/// shows the "arc" from slow to fast with zero benchmark-code changes.
63+
///
64+
/// Corpus: synthetic log-line-like strings (timestamp + level + HTTP method + URL path +
65+
/// status). The fixed vocabulary (levels, methods, path segments, statuses) gives FSST's
66+
/// symbol table real repeated substrings to learn, while the embedded per-row counters and
67+
/// ids keep cardinality high enough that Dict loses the cost competition to FSST — unlike
68+
/// purely-random bytes, which would just hit FSST's escape floor and not exercise the
69+
/// algorithm meaningfully.
70+
///
71+
/// Row count: 1 M across 20 chunks of 50 k. FSST trains per column/per chunk, so this is
72+
/// enough to dominate per-call fixed overhead while staying fast to iterate on; @Setup
73+
/// verifies via the inspector that `vortex.fsst` was actually the selected encoding, so a
74+
/// future dispatch change can't silently turn this into a no-op benchmark of some other
75+
/// encoding.
76+
///
77+
/// Run: java -jar performance/target/benchmarks.jar JavaVsJniFsstBenchmark
78+
@State(Scope.Benchmark)
79+
@BenchmarkMode(Mode.Throughput)
80+
@OutputTimeUnit(TimeUnit.SECONDS)
81+
@Warmup(iterations = 3, time = 3)
82+
@Measurement(iterations = 5, time = 5)
83+
@Fork(value = 1, jvmArgsAppend = {
84+
"--add-opens", "java.base/java.nio=ALL-UNNAMED",
85+
"--enable-native-access=ALL-UNNAMED",
86+
"--sun-misc-unsafe-memory-access=allow"
87+
})
88+
public class JavaVsJniFsstBenchmark {
89+
90+
private static final int TOTAL_ROWS = 1_000_000;
91+
private static final int BATCH_SIZE = 50_000;
92+
private static final int NUM_BATCHES = TOTAL_ROWS / BATCH_SIZE;
93+
94+
private static final ColumnName LINE = ColumnName.of("line");
95+
private static final DType.Struct JAVA_SCHEMA = new DType.Struct(
96+
List.of(LINE), List.of(DType.UTF8), false);
97+
private static final Schema JNI_SCHEMA = new Schema(List.of(
98+
Field.notNullable("line", ArrowType.Utf8.INSTANCE)));
99+
100+
private static final String[] LEVELS = {"INFO", "WARN", "ERROR", "DEBUG", "TRACE"};
101+
private static final String[] METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"};
102+
private static final String[] SEGMENTS = {
103+
"api", "v1", "v2", "users", "orders", "products", "sessions", "auth",
104+
"search", "checkout", "inventory", "billing", "reports", "settings"
105+
};
106+
private static final String[] STATUSES = {
107+
"200 OK", "201 Created", "204 No Content", "301 Moved Permanently",
108+
"400 Bad Request", "401 Unauthorized", "404 Not Found", "500 Internal Server Error"
109+
};
110+
111+
private static final Session SESSION = Session.create();
112+
113+
static {
114+
NativeLoader.loadJni();
115+
}
116+
117+
// Pre-generated per-batch corpus — filled once in @Setup, reused across invocations.
118+
private String[][] batchLines;
119+
private byte[][][] batchLineBytes;
120+
121+
private Path javaWriteFile;
122+
private Path jniWriteFile;
123+
private Path javaReadFile;
124+
private Path jniReadFile;
125+
private ReadRegistry registry;
126+
private BufferAllocator allocator;
127+
128+
@Setup(Level.Trial)
129+
public void setup() throws IOException {
130+
registry = ReadRegistry.loadAll();
131+
allocator = ArrowAllocation.rootAllocator();
132+
133+
javaWriteFile = Files.createTempFile("fsst-java-write", ".vtx");
134+
jniWriteFile = Files.createTempFile("fsst-jni-write", ".vtx");
135+
javaReadFile = Files.createTempFile("fsst-java-read", ".vtx");
136+
jniReadFile = Files.createTempFile("fsst-jni-read", ".vtx");
137+
138+
batchLines = new String[NUM_BATCHES][BATCH_SIZE];
139+
batchLineBytes = new byte[NUM_BATCHES][BATCH_SIZE][];
140+
var rng = new Random(42L);
141+
for (int b = 0; b < NUM_BATCHES; b++) {
142+
for (int i = 0; i < BATCH_SIZE; i++) {
143+
String line = logLine(rng, b * BATCH_SIZE + i);
144+
batchLines[b][i] = line;
145+
batchLineBytes[b][i] = line.getBytes(StandardCharsets.UTF_8);
146+
}
147+
}
148+
149+
// Pre-write the read-benchmark inputs once (not measured), one per implementation.
150+
writeJava(javaReadFile);
151+
writeJni(jniReadFile);
152+
153+
verifyFsstSelected(javaReadFile);
154+
155+
System.out.printf("[JavaVsJniFsstBenchmark] corpus pre-generated: %d rows in %d batches; "
156+
+ "java read file=%.1f KB, jni read file=%.1f KB%n",
157+
TOTAL_ROWS, NUM_BATCHES,
158+
Files.size(javaReadFile) / 1024.0, Files.size(jniReadFile) / 1024.0);
159+
}
160+
161+
@TearDown(Level.Trial)
162+
public void cleanup() throws IOException {
163+
Files.deleteIfExists(javaWriteFile);
164+
Files.deleteIfExists(jniWriteFile);
165+
Files.deleteIfExists(javaReadFile);
166+
Files.deleteIfExists(jniReadFile);
167+
}
168+
169+
/// Java write: encode and write 1 M log lines via Java VortexWriter (FSST column).
170+
@Benchmark
171+
public long javaFsstEncode() throws IOException {
172+
writeJava(javaWriteFile);
173+
return Files.size(javaWriteFile);
174+
}
175+
176+
/// JNI write: encode and write 1 M log lines via Rust VortexWriter.
177+
@Benchmark
178+
public long jniFsstEncode() throws IOException {
179+
writeJni(jniWriteFile);
180+
return Files.size(jniWriteFile);
181+
}
182+
183+
/// Java read: scan the FSST column, sum decoded byte lengths.
184+
@Benchmark
185+
public long javaFsstDecode() throws IOException {
186+
long[] sum = {0L};
187+
try (VortexReader vf = VortexReader.open(javaReadFile, registry);
188+
var iter = vf.scan(io.github.dfa1.vortex.reader.ScanOptions.columns("line"))) {
189+
while (iter.hasNext()) {
190+
try (Chunk c = iter.next()) {
191+
VarBinArray line = c.column("line");
192+
line.forEachByteLength(v -> sum[0] += v);
193+
}
194+
}
195+
}
196+
return sum[0];
197+
}
198+
199+
/// JNI read: scan the FSST column, sum decoded byte lengths.
200+
@Benchmark
201+
public long jniFsstDecode() throws IOException {
202+
String uri = jniReadFile.toAbsolutePath().toUri().toString();
203+
var opts = dev.vortex.api.ScanOptions.builder()
204+
.projection(Expression.select(new String[]{"line"}, Expression.root()))
205+
.build();
206+
207+
long sum = 0L;
208+
DataSource ds = DataSource.open(SESSION, uri);
209+
Scan scan = ds.scan(opts);
210+
while (scan.hasNext()) {
211+
Partition partition = scan.next();
212+
try (ArrowReader reader = partition.scanArrow(allocator)) {
213+
while (reader.loadNextBatch()) {
214+
VectorSchemaRoot root = reader.getVectorSchemaRoot();
215+
// The Rust FSST reader returns an Arrow StringView (ViewVarCharVector), not a
216+
// plain VarCharVector — read through the interface both implement.
217+
VariableWidthFieldVector lineVec = (VariableWidthFieldVector) root.getVector("line");
218+
for (int i = 0; i < root.getRowCount(); i++) {
219+
sum += lineVec.get(i).length;
220+
}
221+
}
222+
}
223+
}
224+
return sum;
225+
}
226+
227+
// ── Corpus + writers ────────────────────────────────────────────────────────
228+
229+
private static String logLine(Random rng, int row) {
230+
String level = LEVELS[rng.nextInt(LEVELS.length)];
231+
String method = METHODS[rng.nextInt(METHODS.length)];
232+
String status = STATUSES[rng.nextInt(STATUSES.length)];
233+
String path = "/" + SEGMENTS[rng.nextInt(SEGMENTS.length)]
234+
+ "/" + SEGMENTS[rng.nextInt(SEGMENTS.length)]
235+
+ "/" + (1000 + rng.nextInt(9_000_000));
236+
// Embedded epoch millis + request id keep cardinality high so FSST — not Dict — wins,
237+
// while the fixed vocabulary above gives the symbol table real repeated substrings.
238+
long millis = 1_700_000_000_000L + (long) row * 37L;
239+
int requestId = row ^ (row << 7);
240+
return millis + " [" + level + "] " + method + " " + path
241+
+ " -> " + status + " req=" + Integer.toHexString(requestId);
242+
}
243+
244+
private void writeJava(Path path) throws IOException {
245+
try (FileChannel ch = FileChannel.open(path,
246+
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
247+
VortexWriter writer = VortexWriter.create(
248+
ch, JAVA_SCHEMA, WriteOptions.cascading(3).withGlobalDict(false))) {
249+
for (int b = 0; b < NUM_BATCHES; b++) {
250+
writer.writeChunk(Map.of(LINE, batchLines[b]));
251+
}
252+
}
253+
}
254+
255+
private void writeJni(Path path) throws IOException {
256+
String uri = path.toAbsolutePath().toUri().toString();
257+
try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create(
258+
SESSION, uri, JNI_SCHEMA, new HashMap<>(), allocator)) {
259+
for (int b = 0; b < NUM_BATCHES; b++) {
260+
flushJni(writer, b);
261+
}
262+
}
263+
}
264+
265+
private void flushJni(dev.vortex.api.VortexWriter writer, int b) throws IOException {
266+
try (VectorSchemaRoot root = VectorSchemaRoot.create(JNI_SCHEMA, allocator)) {
267+
VarCharVector lineVec = (VarCharVector) root.getVector("line");
268+
int n = batchLineBytes[b].length;
269+
lineVec.allocateNew();
270+
for (int i = 0; i < n; i++) {
271+
lineVec.setSafe(i, batchLineBytes[b][i]);
272+
}
273+
root.setRowCount(n);
274+
275+
try (ArrowArray arr = ArrowArray.allocateNew(allocator);
276+
ArrowSchema schema = ArrowSchema.allocateNew(allocator)) {
277+
Data.exportVectorSchemaRoot(allocator, root, null, arr, schema);
278+
writer.writeBatch(arr.memoryAddress(), schema.memoryAddress());
279+
}
280+
}
281+
}
282+
283+
/// Fails loudly if the Java writer did not route the column through `vortex.fsst`, so a
284+
/// future dispatch change can't silently turn the decode benchmarks into a no-op measurement
285+
/// of some other encoding.
286+
private void verifyFsstSelected(Path path) throws IOException {
287+
try (VortexReader vf = VortexReader.open(path, registry)) {
288+
InspectorTree tree = InspectorTree.build(vf);
289+
if (!tree.usedEncodings().contains("vortex.fsst")) {
290+
throw new VortexException("expected vortex.fsst to be selected, but used encodings were "
291+
+ tree.usedEncodings());
292+
}
293+
}
294+
}
295+
}

0 commit comments

Comments
 (0)