Skip to content

Commit c1d77d5

Browse files
dfa1claude
andcommitted
bench: fix apples-to-apples Parquet comparison + update numbers
Add Parquet batch API variants (ColumnReader.nextBatch + getDoubles/getInts) alongside the existing row-cursor variants (renamed RowByRow). Batch is the fair comparison with Vortex's fold — row-by-row was inflating Vortex's apparent speedup by 2.5-3× due to row-cursor API overhead. Results (Apple M5, OpenJDK 25, fork 2, wi=5, i=5): parquetRead (batch) 166.5 ops/s baseline parquetReadRowByRow 67.6 ops/s (2.5× API penalty) vortexRead 235.1 ops/s 1.41× vs batch parquetReadMultiColumn (batch) 133.0 ops/s baseline parquetReadMultiColumnRowByRow 44.0 ops/s (3× API penalty) vortexReadMultiColumn 122.6 ops/s 0.92× vs batch Update explanation.md benchmark table and analysis accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 363a885 commit c1d77d5

2 files changed

Lines changed: 94 additions & 36 deletions

File tree

docs/explanation.md

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -134,49 +134,55 @@ File sizes: Parquet 47.6 MB, Vortex Java 50 MB.
134134

135135
**Environment:** Apple M5, OpenJDK 25, 5 warmup × 3 s, 5 measurement × 5 s, fork 2.
136136

137-
| Benchmark | ops/s | ms/scan | vs Parquet |
138-
|---|---|---|---|
139-
| `parquetRead` — Hardwood, 1 col (`trip_distance`) | 73.9 ± 1.5 | 13.5 ms | baseline |
140-
| `vortexRead` — 1 col (`trip_distance`) | 240.3 ± 3.8 | 4.2 ms | **3.3×** |
141-
| `parquetReadMultiColumn` — 2 cols (`fare_amount`, `PULocationID`) | 45.2 ± 1.2 | 22.1 ms | baseline |
142-
| `vortexReadMultiColumn` — 2 cols (`fare_amount`, `PULocationID`) | 129.1 ± 2.3 | 7.7 ms | **2.9×** |
143-
144-
#### Why Vortex is faster
145-
146-
**1. Batch columnar API vs row-by-row cursor.**
147-
Hardwood's `RowReader` requires `rows.next()` + `rows.getDouble("trip_distance")` per row
148-
— 2 virtual calls × 3 M rows = 6 M calls, plus a string-keyed column lookup on every
149-
access. `DoubleArray.fold()` is a tight loop over a flat `MemorySegment`; the JIT sees a
150-
scalar reduction over contiguous memory with no dispatch overhead.
151-
152-
**2. mmap zero-copy.**
137+
Two Parquet variants are measured to isolate format cost from API overhead:
138+
- **batch**: `ColumnReader.nextBatch()` + loop over `getDoubles()`/`getInts()` arrays — apples-to-apples with Vortex's batch fold
139+
- **row-by-row**: `RowReader.next()` + `getDouble("col")` per row — measures the full row-cursor overhead on top of format decode
140+
141+
| Benchmark | ops/s | vs Parquet batch |
142+
|---|---|---|
143+
| `parquetRead` — batch, 1 col (`trip_distance`) | 166.5 ± 4.0 | baseline |
144+
| `parquetReadRowByRow` — row cursor, 1 col | 67.6 ± 4.4 | 0.41× (2.5× API penalty) |
145+
| `vortexRead` — 1 col (`trip_distance`) | 235.1 ± 6.9 | **1.41×** |
146+
| `parquetReadMultiColumn` — batch, 2 cols (`fare_amount`, `PULocationID`) | 133.0 ± 18.3 | baseline |
147+
| `parquetReadMultiColumnRowByRow` — row cursor, 2 cols | 44.0 ± 2.2 | 0.33× (3× API penalty) |
148+
| `vortexReadMultiColumn` — 2 cols | 122.6 ± 3.3 | 0.92× |
149+
150+
Single-column: Vortex 1.4× faster than Parquet batch — format advantage is real (mmap
151+
zero-copy + ALP vs Parquet RLE/ZSTD page decode).
152+
153+
Multi-column: Vortex (122.6) slightly behind Parquet batch (133.0). Known gap: Rust uses a
154+
global dict per column (one tiny dict for all 3 M rows); Java applies dict per 131 K-row
155+
chunk, increasing per-chunk overhead for low-cardinality columns like `PULocationID` (260
156+
unique values).
157+
158+
#### Why Vortex is faster on single-column reads
159+
160+
**1. mmap zero-copy.**
153161
Vortex reads directly from the mmap'd `MemorySegment` — the file bytes _are_ the decode
154162
input, no intermediate copies. Hardwood reads into internal page buffers and materialises
155-
values into a row cursor (one extra copy per page). Parquet also pays per-page framing
156-
overhead: RLE-encoded definition/repetition levels, page header parsing, optional dictionary
157-
decode. Vortex's layout is a flat array of encoded values with no per-row framing.
163+
values before batch hand-off. Parquet also pays per-page framing overhead: RLE-encoded
164+
definition/repetition levels, page header parsing, optional dictionary decode. Vortex's
165+
layout is a flat array of encoded values with no per-row framing.
158166

159-
**3. Typed scatter instead of per-element copy.**
167+
**2. Typed scatter instead of per-element copy.**
160168
`DictEncoding` expansion uses `getAtIndex`/`setAtIndex` with loop-unswitched elemSize —
161-
a single typed load + store per row. Prior to this fix, each element was expanded via
162-
`MemorySegment.copy(8 bytes)`, which carries per-call bounds-check overhead and dominated
163-
60% of JFR execution samples on multi-column scans.
169+
a single typed load + store per row. The prior `MemorySegment.copy(8 bytes)` per element
170+
dominated 60% of JFR execution samples on multi-column scans before it was fixed.
164171

165172
```
166173
Hardwood parquetRead (per 3 M rows) Vortex vortexRead (per 3 M rows)
167174
──────────────────────────────────── ──────────────────────────────────
168175
47.6 MB on disk 50 MB on disk
169176
+ page header parse × N pages + ALP decode (branch-free ×/+)
170177
+ definition-level RLE decode × 3 M rows + fold() tight loop, no dispatch
171-
+ getDouble("col") × 3 M virtual calls
172178
```
173179

174180
#### Why ZstdEncoding is excluded from the numeric cascade
175181

176182
Adding `ZstdEncoding` to `CASCADE_CODECS` improves file size (50 MB → 43 MB) because
177183
Zstd out-compresses ALP on some F64 columns. But ZSTD decompression is an order of
178184
magnitude slower than ALP reconstruction or bitpack unpack: single-column read throughput
179-
collapses from 240 to 40 ops/s (6×), falling below Parquet's 73.9 ops/s baseline.
185+
collapses from 235 to 40 ops/s (6×), falling below Parquet batch (166.5 ops/s).
180186

181187
The smaller file is not worth the read regression. `ZstdEncoding` is retained in the
182188
codec registry for `Utf8`/`Binary` columns where no faster structural alternative exists,

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

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.github.dfa1.vortex.performance;
22

33
import dev.hardwood.InputFile;
4+
import dev.hardwood.reader.ColumnReader;
5+
import dev.hardwood.reader.ColumnReaders;
46
import dev.hardwood.reader.ParquetFileReader;
57
import dev.hardwood.reader.RowReader;
68
import dev.hardwood.schema.ColumnProjection;
@@ -35,15 +37,20 @@
3537

3638
/// Benchmark: Hardwood Parquet read vs Java Vortex read on the same real-world dataset.
3739
///
38-
/// Dataset: NYC Yellow Taxi 2024-01 (~3M rows), three columns:
39-
/// trip_distance (DOUBLE), fare_amount (DOUBLE), PULocationID (INT32)
40+
/// Dataset: NYC Yellow Taxi 2024-01 (~3M rows).
41+
/// Columns: trip_distance (DOUBLE), fare_amount (DOUBLE), PULocationID (INT32).
4042
///
41-
/// Setup downloads the Parquet file once per trial (cached in /tmp), converts it to
42-
/// Vortex with column projection, then both benchmarks scan and sum trip_distance.
43+
/// Two Parquet variants:
44+
/// - {@code parquetRead} / {@code parquetReadMultiColumn}: batch column API
45+
/// ({@link ColumnReader#nextBatch()} + {@link ColumnReader#getDoubles()}) —
46+
/// apples-to-apples comparison with Vortex's batch fold.
47+
/// - {@code parquetReadRowByRow} / {@code parquetReadMultiColumnRowByRow}:
48+
/// Hardwood row cursor ({@link RowReader}) — measures row-oriented API overhead
49+
/// on top of format decode cost.
4350
///
44-
/// Override the Parquet source with: -Dbench.parquet=/path/to/file.parquet
51+
/// Override the Parquet source with: {@code -Dbench.parquet=/path/to/file.parquet}
4552
///
46-
/// Run: java -jar performance/target/benchmarks.jar ParquetVsVortexReadBenchmark
53+
/// Run: {@code java -jar performance/target/benchmarks.jar ParquetVsVortexReadBenchmark}
4754
@State(Scope.Benchmark)
4855
@BenchmarkMode(Mode.Throughput)
4956
@OutputTimeUnit(TimeUnit.SECONDS)
@@ -106,9 +113,50 @@ public void tearDown() throws IOException {
106113
}
107114
}
108115

109-
/// Hardwood: scan all rows, sum trip_distance (single column).
116+
// ── Batch API (apples-to-apples with Vortex) ─────────────────────────────
117+
118+
/// Hardwood batch: decode trip_distance column in chunks, sum all values.
110119
@Benchmark
111120
public double parquetRead() throws IOException {
121+
double sum = 0.0;
122+
try (ParquetFileReader reader = ParquetFileReader.open(InputFile.of(parquetFile));
123+
ColumnReader cr = reader.columnReader("trip_distance")) {
124+
while (cr.nextBatch()) {
125+
double[] vals = cr.getDoubles();
126+
int n = cr.getRecordCount();
127+
for (int i = 0; i < n; i++) {
128+
sum += vals[i];
129+
}
130+
}
131+
}
132+
return sum;
133+
}
134+
135+
/// Hardwood batch: decode fare_amount + PULocationID in chunks, sum both.
136+
@Benchmark
137+
public double parquetReadMultiColumn() throws IOException {
138+
double fareSum = 0.0;
139+
long idSum = 0L;
140+
try (ParquetFileReader reader = ParquetFileReader.open(InputFile.of(parquetFile));
141+
ColumnReaders crs = reader.columnReaders(ColumnProjection.columns("fare_amount", "PULocationID"))) {
142+
while (crs.nextBatch()) {
143+
int n = crs.getRecordCount();
144+
double[] fares = crs.getColumnReader("fare_amount").getDoubles();
145+
int[] locs = crs.getColumnReader("PULocationID").getInts();
146+
for (int i = 0; i < n; i++) {
147+
fareSum += fares[i];
148+
idSum += locs[i];
149+
}
150+
}
151+
}
152+
return fareSum + idSum;
153+
}
154+
155+
// ── Row cursor API (measures row-oriented overhead on top of decode) ──────
156+
157+
/// Hardwood row cursor: sum trip_distance, one virtual call per row.
158+
@Benchmark
159+
public double parquetReadRowByRow() throws IOException {
112160
double sum = 0.0;
113161
ColumnProjection projection = ColumnProjection.columns("trip_distance");
114162
try (ParquetFileReader reader = ParquetFileReader.open(InputFile.of(parquetFile));
@@ -121,9 +169,9 @@ public double parquetRead() throws IOException {
121169
return sum;
122170
}
123171

124-
/// Hardwood: scan all rows, sum fare_amount + PULocationID (two columns).
172+
/// Hardwood row cursor: sum fare_amount + PULocationID, two virtual calls per row.
125173
@Benchmark
126-
public double parquetReadMultiColumn() throws IOException {
174+
public double parquetReadMultiColumnRowByRow() throws IOException {
127175
double fareSum = 0.0;
128176
long idSum = 0L;
129177
ColumnProjection projection = ColumnProjection.columns("fare_amount", "PULocationID");
@@ -138,7 +186,9 @@ public double parquetReadMultiColumn() throws IOException {
138186
return fareSum + idSum;
139187
}
140188

141-
/// Vortex: scan all rows, sum trip_distance via fold.
189+
// ── Vortex ────────────────────────────────────────────────────────────────
190+
191+
/// Vortex: decode trip_distance in chunks, sum via batch fold.
142192
@Benchmark
143193
public double vortexRead() throws IOException {
144194
double sum = 0.0;
@@ -153,7 +203,7 @@ public double vortexRead() throws IOException {
153203
return sum;
154204
}
155205

156-
/// Vortex: scan fare_amount + PULocationID, sum both (exercises multi-column projection).
206+
/// Vortex: decode fare_amount + PULocationID in chunks, sum both via batch fold.
157207
@Benchmark
158208
public double vortexReadMultiColumn() throws IOException {
159209
double fareSum = 0.0;
@@ -171,6 +221,8 @@ public double vortexReadMultiColumn() throws IOException {
171221
return fareSum + idSum;
172222
}
173223

224+
// ── Setup helper ──────────────────────────────────────────────────────────
225+
174226
private static Path downloadIfAbsent(Path dest, String url) throws Exception {
175227
if (Files.exists(dest)) {
176228
System.out.printf("[ParquetVsVortexReadBenchmark] cache hit: %s%n", dest);

0 commit comments

Comments
 (0)