Skip to content

Commit 900bb9e

Browse files
dfa1claude
andcommitted
feat: add VarBinCodec and symbol column benchmark
- VarBinCodec decodes vortex.varbin (bytes + I32/I64 offsets) - ConstantCodec.decodeString: O(1) bytes via alternating offsets into single shared buffer instead of n copies - RustVsJavaReadBenchmark: add jniReadSymbol/javaReadSymbol (UTF-8) and jniReadClose/javaReadClose (ALP F64) - README: add symbol row to benchmark table (Java 2.5× vs JNI) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2ba5ab3 commit 900bb9e

6 files changed

Lines changed: 177 additions & 14 deletions

File tree

README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ Pure-Java reader/writer for the [Vortex](https://github.com/spiraldb/vortex) col
88

99
`RustVsJavaReadBenchmark` — 10M OHLC rows, JMH throughput (higher = better), Apple M-series, Java 25:
1010

11-
| Column | Encoding | vortex-jni | vortex-java | ratio |
12-
|-----------------|---------------|-------------|--------------|---------------|
13-
| `volume` (I64) | primitive | 51.9 ops/s | 120.7 ops/s | **Java 2.3×** |
14-
| `close` (F64) | ALP | 62.6 ops/s | 118.0 ops/s | **Java 1.9×** |
15-
16-
Both columns decoded faster in pure Java than via JNI + Apache Arrow. Key optimisations on
17-
the ALP path: `static final` ValueLayout constants (JIT constant-folding), aligned arena
18-
allocation (`allocate(n, alignment)`), and `getAtIndex()`/`setAtIndex()` instead of raw byte
19-
offsets (clearer stride for the auto-vectoriser).
11+
| Column | Encoding | vortex-jni | vortex-java | ratio |
12+
|------------------|-------------------|-------------|--------------|---------------|
13+
| `volume` (I64) | primitive | 51.9 ops/s | 120.7 ops/s | **Java 2.3×** |
14+
| `close` (F64) | ALP | 62.6 ops/s | 118.0 ops/s | **Java 1.9×** |
15+
| `symbol` (UTF-8) | constant (varbin) | 10.9 ops/s | 27.8 ops/s | **Java 2.5×** |
16+
17+
All columns decoded faster in pure Java than via JNI + Apache Arrow. Key optimisations:
18+
`static final` ValueLayout constants (JIT constant-folding), aligned arena allocation
19+
(`allocate(n, alignment)`), `getAtIndex()`/`setAtIndex()` (clearer stride for the
20+
auto-vectoriser), and O(1) bytes allocation for constant strings (alternating offsets
21+
into a single shared byte buffer).
2022

2123
## Motivation
2224

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.lang.foreign.ValueLayout;
1212
import java.nio.ByteBuffer;
1313
import java.nio.ByteOrder;
14+
import java.nio.charset.StandardCharsets;
1415

1516
/// Decoder for {@code vortex.constant} — all elements share the same value.
1617
///
@@ -56,10 +57,6 @@ public EncodeResult encode(DType dtype, Object data) {
5657

5758
@Override
5859
public Array decode(DecodeContext ctx) {
59-
if (!(ctx.dtype() instanceof DType.Primitive p)) {
60-
throw new IllegalStateException("vortex.constant: expected primitive dtype, got " + ctx.dtype());
61-
}
62-
6360
MemorySegment scalarBuf = ctx.buffer(0);
6461
byte[] scalarBytes = scalarBuf.toArray(ValueLayout.JAVA_BYTE);
6562

@@ -70,8 +67,17 @@ public Array decode(DecodeContext ctx) {
7067
throw new IllegalStateException("vortex.constant: invalid scalar value", e);
7168
}
7269

73-
PType ptype = p.ptype();
7470
long n = ctx.rowCount();
71+
72+
if (ctx.dtype() instanceof DType.Utf8 || ctx.dtype() instanceof DType.Binary) {
73+
return decodeString(ctx, scalar, n);
74+
}
75+
76+
if (!(ctx.dtype() instanceof DType.Primitive p)) {
77+
throw new IllegalStateException("vortex.constant: unsupported dtype " + ctx.dtype());
78+
}
79+
80+
PType ptype = p.ptype();
7581
int elemBytes = ptype.byteSize();
7682
long rawBits = scalarToRawBits(scalar, ptype);
7783

@@ -84,4 +90,33 @@ public Array decode(DecodeContext ctx) {
8490
return new Array(ctx.dtype(), n,
8591
new MemorySegment[]{outSeg.asReadOnly()}, Array.NO_CHILDREN, ArrayStats.empty());
8692
}
93+
94+
private static Array decodeString(DecodeContext ctx, ScalarProtos.ScalarValue scalar, long n) {
95+
byte[] strBytes = scalar.hasStringValue()
96+
? scalar.getStringValue().getBytes(StandardCharsets.UTF_8)
97+
: scalar.getBytesValue().toByteArray();
98+
99+
int strLen = strBytes.length;
100+
101+
// Store the string bytes once; all n offsets point into the same [0, strLen] range.
102+
// Offsets layout: [0, strLen, 0, strLen, ...] — each pair encodes the same slice.
103+
MemorySegment bytesSeg = ctx.arena().allocate(strLen);
104+
MemorySegment.copy(MemorySegment.ofArray(strBytes), 0, bytesSeg, 0, strLen);
105+
106+
// Offsets: n+1 I32 values, alternating start/end of the single string.
107+
MemorySegment offsetsSeg = ctx.arena().allocate((n + 1) * 4L, 4);
108+
var layout = ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
109+
for (long i = 0; i <= n; i++) {
110+
// even index → 0 (start), odd → strLen (end); wraps back to 0 for the next string's start
111+
offsetsSeg.setAtIndex(layout, i, (i % 2 == 0) ? 0 : strLen);
112+
}
113+
114+
Array offsets = new Array(new DType.Primitive(PType.I32, false), n + 1,
115+
new MemorySegment[]{offsetsSeg.asReadOnly()}, Array.NO_CHILDREN, ArrayStats.empty());
116+
117+
return new Array(ctx.dtype(), n,
118+
new MemorySegment[]{bytesSeg.asReadOnly()},
119+
new Array[]{offsets},
120+
ArrayStats.empty());
121+
}
87122
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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.nio.ByteBuffer;
12+
13+
/// Decoder for {@code vortex.varbin} — variable-length binary / UTF-8 string arrays.
14+
///
15+
/// <p>Metadata: protobuf {@code VarBinMetadata} — {@code offsets_ptype PType} (tag 1).
16+
///
17+
/// <p>Buffer 0: concatenated raw bytes of all strings.
18+
/// Child slot 0: offsets array (length = rowCount + 1, dtype = offsets_ptype).
19+
/// {@code offsets[i]..offsets[i+1]} is the byte range of element {@code i} in the bytes buffer.
20+
/// Child slot 1 (optional): validity array — ignored for non-nullable columns.
21+
///
22+
/// <p>The returned {@code Array} exposes:
23+
/// <ul>
24+
/// <li>{@code buffer(0)} — the raw bytes segment</li>
25+
/// <li>{@code child(0)} — the offsets array (I32 or I64 primitives)</li>
26+
/// </ul>
27+
public final class VarBinCodec implements Codec {
28+
29+
@Override
30+
public CodecId encodingId() {
31+
return CodecId.VORTEX_VARBIN;
32+
}
33+
34+
@Override
35+
public EncodeResult encode(DType dtype, Object data) {
36+
throw new UnsupportedOperationException("encode not supported by " + encodingId());
37+
}
38+
39+
@Override
40+
public Array decode(DecodeContext ctx) {
41+
ByteBuffer rawMeta = ctx.metadata();
42+
if (rawMeta == null) {
43+
throw new IllegalStateException("vortex.varbin: missing metadata");
44+
}
45+
EncodingProtos.VarBinMetadata meta;
46+
try {
47+
meta = EncodingProtos.VarBinMetadata.parseFrom(rawMeta.duplicate());
48+
} catch (InvalidProtocolBufferException e) {
49+
throw new IllegalStateException("vortex.varbin: invalid metadata", e);
50+
}
51+
52+
PType offsetsPtype = PType.values()[meta.getOffsetsPtype().getNumber()];
53+
DType offsetsDtype = new DType.Primitive(offsetsPtype, false);
54+
long n = ctx.rowCount();
55+
56+
// Offsets: n+1 elements; bytes: raw string data.
57+
ArrayNode offsetsNode = ctx.node().children()[0];
58+
DecodeContext offsetsCtx = new DecodeContext(
59+
offsetsNode, offsetsDtype, n + 1,
60+
ctx.segmentBuffers(), ctx.registry(), ctx.arena());
61+
Array offsets = ctx.registry().decode(offsetsCtx);
62+
63+
MemorySegment bytes = ctx.buffer(0);
64+
65+
return new Array(ctx.dtype(), n,
66+
new MemorySegment[]{bytes},
67+
new Array[]{offsets},
68+
ArrayStats.empty());
69+
}
70+
}

core/src/main/proto/encodings.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ message SequenceMetadata {
5050
vortex.scalar.ScalarValue base = 1;
5151
vortex.scalar.ScalarValue multiplier = 2;
5252
}
53+
54+
message VarBinMetadata {
55+
vortex.dtype.PType offsets_ptype = 1;
56+
}

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
@@ -9,3 +9,4 @@ io.github.dfa1.vortex.encoding.SparseCodec
99
io.github.dfa1.vortex.encoding.AlpCodec
1010
io.github.dfa1.vortex.encoding.RunEndCodec
1111
io.github.dfa1.vortex.encoding.ConstantCodec
12+
io.github.dfa1.vortex.encoding.VarBinCodec

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,32 @@ public double jniReadClose() throws IOException {
183183
return sum;
184184
}
185185

186+
/// JNI read: project on "symbol" (short UTF-8 string, varbin), sum byte lengths.
187+
@Benchmark
188+
public long jniReadSymbol() throws IOException {
189+
String uri = benchFile.toAbsolutePath().toUri().toString();
190+
var opts = ScanOptions.builder()
191+
.projection(Expression.select(new String[]{"symbol"}, Expression.root()))
192+
.build();
193+
194+
long sum = 0L;
195+
DataSource ds = DataSource.open(SESSION, uri);
196+
Scan scan = ds.scan(opts);
197+
while (scan.hasNext()) {
198+
Partition partition = scan.next();
199+
try (ArrowReader reader = partition.scanArrow(allocator)) {
200+
while (reader.loadNextBatch()) {
201+
VectorSchemaRoot root = reader.getVectorSchemaRoot();
202+
VarCharVector symbolVec = (VarCharVector) root.getVector("symbol");
203+
for (int i = 0; i < root.getRowCount(); i++) {
204+
sum += symbolVec.getObject(i).getBytes().length;
205+
}
206+
}
207+
}
208+
}
209+
return sum;
210+
}
211+
186212
// ── JNI file generation ───────────────────────────────────────────────────
187213

188214
/// Java read: project on "volume", sum all values.
@@ -225,6 +251,31 @@ public double javaReadClose() throws IOException {
225251
return sum;
226252
}
227253

254+
/// Java read: project on "symbol" (short UTF-8 string, varbin), sum byte lengths.
255+
@Benchmark
256+
public long javaReadSymbol() throws IOException {
257+
long sum = 0L;
258+
try (VortexReader vf = VortexReader.open(benchFile, registry)) {
259+
var iter = vf.scan(io.github.dfa1.vortex.scan.ScanOptions.columns("symbol"));
260+
while (iter.hasNext()) {
261+
ScanResult r = iter.next();
262+
Array arr = r.columns().get("symbol");
263+
// buffer(0) = raw bytes; child(0) = offsets
264+
MemorySegment bytes = arr.buffer(0);
265+
Array offsets = arr.child(0);
266+
MemorySegment offsetSeg = offsets.buffer(0);
267+
var offsetLayout = ValueLayout.JAVA_INT_UNALIGNED;
268+
long n = arr.length();
269+
for (long i = 0; i < n; i++) {
270+
int start = offsetSeg.getAtIndex(offsetLayout, i);
271+
int end = offsetSeg.getAtIndex(offsetLayout, i + 1);
272+
sum += end - start;
273+
}
274+
}
275+
}
276+
return sum;
277+
}
278+
228279
private void writeJni(Path path) throws IOException {
229280
String uri = path.toAbsolutePath().toUri().toString();
230281
try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create(

0 commit comments

Comments
 (0)