Skip to content

Commit ea10d37

Browse files
dfa1claude
andcommitted
feat(writer): add WriteOptions.withZstd(boolean) for opt-in size/throughput tradeoff
Add `enableZstd` field to WriteOptions and `withZstd(boolean)` fluent method. When enabled, ZstdEncoding is inserted into the cascade codec list and competes with ALP/bitpack on every chunk — recovers the ~43 MB NYC taxi level (vs 49.7 MB without Zstd) at the cost of ~6× slower Java read throughput. Default remains false (good throughput). Users targeting archival or Rust-read workloads can opt in via WriteOptions.cascading(3).withZstd(true). Also marks PCO encode E0 gate as cleared: VortexWriter cascade is the consumer. Next step: E1 (LeBitWriter). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d383765 commit ea10d37

6 files changed

Lines changed: 169 additions & 38 deletions

File tree

TODO.md

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,33 @@
99
comparison via `?source=url1,url2`; then drop `.github/workflows/benchmark.yml`
1010
- [ ] Build something like hardwood.dev but for vortex files
1111

12-
## Compression ratio gaps vs Rust (NYC taxi 2024-01: Java 43.1 MB, Rust 42.8 MB, Parquet 47.6 MB)
12+
## Compression ratio gaps vs Rust (NYC taxi 2024-01: Rust 42.8 MB, Parquet 47.6 MB)
1313

1414
Progress: raw I64 → datetimeparts (76→53 MB), fix FOR/RLE/RunEnd accepts + sample size (53→51 MB),
15-
fix ConstantEncoding encodeCascade + add ZstdEncoding to CASCADE_CODECS (51→43.1 MB). Java now beats
16-
Parquet and is within 0.3 MB of Rust.
15+
fix ConstantEncoding encodeCascade (51→43.1 MB with Zstd; 49.7 MB without Zstd).
16+
Global dict encoding for low-cardinality integer columns (VendorID, PULocationID, etc.) — completed.
17+
`WriteOptions.withZstd(true)` — completed: adds ZstdEncoding to cascade, recovers the 43 MB level.
1718

18-
Remaining 0.3 MB gap — biggest to smallest:
19+
Without Zstd (default, good read throughput): Java 49.7 MB — 6.5 MB above Rust.
20+
With Zstd (`WriteOptions.cascading(3).withZstd(true)`): Java ~43 MB — on par with Rust.
21+
Trade-off documented in `WriteOptions` Javadoc (6× slower Zstd decompression vs ALP+bitpack).
1922

20-
- [ ] **Global dict encoding** — Rust applies `vortex.dict` across the ENTIRE column before chunking; produces one
21-
tiny dict buffer + one globally-bitpacked codes array. Java applies dict per 131 072-row chunk. Low-cardinality
22-
columns affected: `mta_tax` (8 unique F64), `Airport_fee` (4), `extra` (48), `PULocationID` (260), `DOLocationID` (
23-
261),
24-
`payment_type` (5), `store_and_fwd_flag` (3), `congestion_surcharge` (few), `tolls_amount` (1127).
25-
Requires a two-pass write pipeline: first pass collects all values and builds the global dict; second pass emits
26-
coded chunks. Estimated remaining gain ~0.3 MB (most gains already captured by ZstdEncoding on per-chunk dicts).
23+
Remaining gap (no-Zstd mode) — biggest to smallest:
24+
25+
- [ ] **Nullable column handling**`ParquetImporter` maps nulls to 0.0/0L (type defaults) for the 9 nullable F64
26+
columns in the taxi dataset (`fare_amount`, `extra`, `mta_tax`, `tip_amount`, `tolls_amount`,
27+
`improvement_surcharge`, `total_amount`, `congestion_surcharge`, `Airport_fee`). Rust uses `vortex.sparse`
28+
or `vortex.masked` to store only valid values, then ALP on clean data. Java passes zero-polluted arrays to ALP.
29+
Fix: add a `NullableData(double[] values, boolean[] validity)` wrapper; writer detects it, compacts valid values,
30+
encodes validity as a Bool child. Requires API change to `VortexWriter.writeChunk` and corresponding
31+
`ParquetImporter` changes.
32+
33+
- [ ] **Global dict for F64 low-cardinality** — excluded from `isDictCandidate` because ALP/RLE were expected to
34+
win; but for columns like `mta_tax` (8 unique F64 values) and `Airport_fee` (4 unique), dict codes are
35+
~same size as ALP+bitpack while Rust uses dict. Measure actual gain before implementing.
36+
37+
- [ ] **FSST in CASCADE_CODECS**`FsstEncoding` exists but not in the cascade; Rust uses FSST for
38+
`store_and_fwd_flag`. Small gain on taxi (~0.1 MB).
2739

2840
## Performance
2941

@@ -153,8 +165,8 @@ on any S3 fixture (all fixtures are Rust-produced; decode unblocks them).
153165

154166
**Phases**:
155167

156-
- [ ] **Phase E0 — gate**. Is there a consumer (CLI write path, vortex-arrow bridge)? If no,
157-
stop. Decode is enough.
168+
- [x] **Phase E0 — gate**. Consumer = `VortexWriter` cascade (`WriteOptions.cascading(3)`) — closes
169+
the NYC taxi compression gap vs Rust without the 6× Zstd read penalty. Gate cleared.
158170
- [ ] **Phase E1 — bit writer**. `LeBitWriter` over `Arena`-backed `MemorySegment`. Mirrors
159171
`pco/src/bit_writer.rs`. Property test: random bit sequences round-trip via `LeBitReader`.
160172
- [ ] **Phase E2 — Classic mode, no delta, fixed bins, no optimization**. Hardcoded bin layout
@@ -198,7 +210,8 @@ on any S3 fixture (all fixtures are Rust-produced; decode unblocks them).
198210
but suboptimal" encoder (Phase E1+E2+E5+E8 partial). Decode is the prerequisite —
199211
don't start before decode lands.
200212

201-
**Decision**: keep `Encoder` stub until a real write consumer materializes. Reassess
202-
post-decode + post-`vortex-arrow` bridge.
213+
**Decision**: E0 gate cleared — start E1 (LeBitWriter). Suboptimal path E1+E2+E9 (~5 days)
214+
gives Rust-compatible wire format first; E3+E4 add ratio parity (~+5 days); E5+E6 add delta +
215+
mode selection (~+5 days). Tackle E1 next.
203216

204217

integration/src/test/java/io/github/dfa1/vortex/integration/FileSizeComparisonIntegrationTest.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,18 @@ private static Path writeCsv(Path dir, List<OhlcGenerator.OhlcBatch> batches) th
9999
}
100100

101101
private static Path writeJava(Path dir, List<OhlcGenerator.OhlcBatch> batches) throws IOException {
102-
Path file = dir.resolve("ohlc-java.vtx");
102+
return writeJava(dir, "ohlc-java.vtx", WriteOptions.cascading(3), batches);
103+
}
104+
105+
private static Path writeJavaZstd(Path dir, List<OhlcGenerator.OhlcBatch> batches) throws IOException {
106+
return writeJava(dir, "ohlc-java-zstd.vtx", WriteOptions.cascading(3).withZstd(true), batches);
107+
}
108+
109+
private static Path writeJava(Path dir, String filename, WriteOptions opts,
110+
List<OhlcGenerator.OhlcBatch> batches) throws IOException {
111+
Path file = dir.resolve(filename);
103112
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
104-
VortexWriter writer = VortexWriter.create(ch, JAVA_SCHEMA, WriteOptions.cascading(3))) {
113+
VortexWriter writer = VortexWriter.create(ch, JAVA_SCHEMA, opts)) {
105114
for (OhlcGenerator.OhlcBatch b : batches) {
106115
writer.writeChunk(Map.of(
107116
"symbol", b.symbols(), "date", b.dates(),
@@ -200,4 +209,36 @@ void fileSizeComparison(@TempDir Path tmp) throws IOException {
200209
}
201210
assertThat(totalRows).isEqualTo(TOTAL_ROWS);
202211
}
212+
213+
@Test
214+
void withZstd_smallerFile_and_readable(@TempDir Path tmp) throws IOException {
215+
// Given
216+
List<OhlcGenerator.OhlcBatch> batches = OhlcGenerator.generate(TOTAL_ROWS, BATCH_SIZE);
217+
218+
// When
219+
Path noZstd = writeJava(tmp, batches);
220+
Path withZstd = writeJavaZstd(tmp, batches);
221+
222+
long noZstdSize = Files.size(noZstd);
223+
long zstdSize = Files.size(withZstd);
224+
225+
System.out.printf(
226+
"[ZstdComparison] %,d rows noZstd=%,d bytes (%.1f MB) withZstd=%,d bytes (%.1f MB) ratio=%.2fx%n",
227+
TOTAL_ROWS, noZstdSize, noZstdSize / 1_048_576.0,
228+
zstdSize, zstdSize / 1_048_576.0,
229+
(double) noZstdSize / zstdSize);
230+
231+
// Then — Zstd produces a smaller file for OHLC F64 data
232+
assertThat(zstdSize).isLessThan(noZstdSize);
233+
234+
// Then — Zstd file is readable with correct row count
235+
long totalRows = 0L;
236+
try (VortexReader reader = VortexReader.open(withZstd, EncodingRegistry.loadAll())) {
237+
var iter = reader.scan(io.github.dfa1.vortex.scan.ScanOptions.columns("volume"));
238+
while (iter.hasNext()) {
239+
totalRows += iter.next().<LongArray>column("volume").length();
240+
}
241+
}
242+
assertThat(totalRows).isEqualTo(TOTAL_ROWS);
243+
}
203244
}

integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,26 @@ void javaWriter_rustReader_listView_i64(@TempDir Path tmp) throws IOException {
11061106
assertThat(flatElements).containsExactly(elements);
11071107
}
11081108

1109+
@Test
1110+
void javaWriter_rustReader_cascading_withZstd_f64(@TempDir Path tmp) throws IOException {
1111+
// Given — Zstd wins the cascade for high-cardinality F64; Rust must decode vortex.zstd
1112+
Path file = tmp.resolve("java_zstd_cascade_f64.vtx");
1113+
int n = 2_000;
1114+
double[] data = new double[n];
1115+
for (int i = 0; i < n; i++) {
1116+
data[i] = i * 1.37 + 3.14;
1117+
}
1118+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
1119+
var sut = VortexWriter.create(ch, F64_SCHEMA, WriteOptions.cascading(3).withZstd(true))) {
1120+
// When
1121+
sut.writeChunk(Map.of("v", data));
1122+
}
1123+
1124+
// Then — Rust reader must decode the file (whether via ALP or Zstd, value-equal)
1125+
double[] decoded = readDoubleColumn(file, "v");
1126+
assertBitwiseEqualsF64(decoded, data);
1127+
}
1128+
11091129
@Test
11101130
void javaWriter_rustReader_globalDict_i64(@TempDir Path tmp) throws IOException {
11111131
// Given — low-cardinality I64 column triggers global dict across two chunks

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import io.github.dfa1.vortex.encoding.EncodingRegistry;
1212
import io.github.dfa1.vortex.io.VortexInspector;
1313
import io.github.dfa1.vortex.io.VortexReader;
14+
import io.github.dfa1.vortex.parquet.ImportOptions;
1415
import io.github.dfa1.vortex.parquet.ParquetImporter;
16+
import io.github.dfa1.vortex.writer.WriteOptions;
1517
import org.apache.arrow.c.ArrowArray;
1618
import org.apache.arrow.c.ArrowSchema;
1719
import org.apache.arrow.c.Data;
@@ -99,6 +101,7 @@ public static void main(String[] args) throws Exception {
99101

100102
Path jniVortex = Files.createTempFile("taxi-jni", ".vortex");
101103
Path javaVortex = Files.createTempFile("taxi-java", ".vortex");
104+
Path javaZstdVortex = Files.createTempFile("taxi-java-zstd", ".vortex");
102105

103106
try {
104107
System.out.print("Writing JNI Vortex (Rust encoder)...");
@@ -109,18 +112,30 @@ public static void main(String[] args) throws Exception {
109112
ParquetImporter.importParquet(parquetFile, javaVortex);
110113
System.out.printf(" %6.1f MB%n", mb(javaVortex));
111114

115+
System.out.print("Writing Java Vortex (cascading depth 3 + Zstd)...");
116+
ImportOptions zstdOpts = ImportOptions.defaults()
117+
.withWriteOptions(WriteOptions.cascading(3).withZstd(true));
118+
ParquetImporter.importParquet(parquetFile, javaZstdVortex, zstdOpts);
119+
System.out.printf(" %6.1f MB%n", mb(javaZstdVortex));
120+
112121
System.out.println("\n══════════════════════════════════════════════════════");
113122
System.out.println(" JNI / Rust writer");
114123
System.out.println("══════════════════════════════════════════════════════");
115124
inspect(jniVortex);
116125

117126
System.out.println("\n══════════════════════════════════════════════════════");
118-
System.out.println(" Java writer");
127+
System.out.println(" Java writer (no Zstd)");
119128
System.out.println("══════════════════════════════════════════════════════");
120129
inspect(javaVortex);
130+
131+
System.out.println("\n══════════════════════════════════════════════════════");
132+
System.out.println(" Java writer (with Zstd)");
133+
System.out.println("══════════════════════════════════════════════════════");
134+
inspect(javaZstdVortex);
121135
} finally {
122136
Files.deleteIfExists(jniVortex);
123137
Files.deleteIfExists(javaVortex);
138+
Files.deleteIfExists(javaZstdVortex);
124139
}
125140
}
126141

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io.github.dfa1.vortex.encoding.RleEncoding;
2525
import io.github.dfa1.vortex.encoding.RunEndEncoding;
2626
import io.github.dfa1.vortex.encoding.VarBinEncoding;
27+
import io.github.dfa1.vortex.encoding.ZstdEncoding;
2728
import io.github.dfa1.vortex.fbs.ArraySpec;
2829
import io.github.dfa1.vortex.fbs.Extension;
2930
import io.github.dfa1.vortex.fbs.Footer;
@@ -82,25 +83,16 @@ public final class VortexWriter implements Closeable {
8283
private static final List<Encoding> DEFAULT_CODECS = List.of(
8384
new AlpEncoding(), new PrimitiveEncoding(), new BoolEncoding(), new DictEncoding(), new VarBinEncoding());
8485

85-
// ZstdEncoding is intentionally absent from this list.
86-
// On NYC Taxi 2024-01: Zstd wins compression for F64 columns (50 MB → 43 MB) but beats
87-
// ALP and kills decode throughput by 6× (240 → 40 ops/s). ZSTD decompression cannot
88-
// compete with ALP reconstruction or bitpack unpack for numeric columns.
89-
// Use ZstdEncoding only for Utf8/Binary where no faster structural encoding exists.
90-
private static final List<Encoding> CASCADE_CODECS = List.of(
91-
new DateTimePartsEncoding(),
92-
new ConstantEncoding(),
93-
new AlpEncoding(), new FrameOfReferenceEncoding(), new RunEndEncoding(), new RleEncoding(),
94-
new DictEncoding(), new BitpackedEncoding(),
95-
new VarBinEncoding(), new PrimitiveEncoding(), new BoolEncoding());
96-
97-
private static final EncodingRegistry CASCADE_REGISTRY = EncodingRegistry.of(CASCADE_CODECS);
86+
// Base cascade codec list — no Zstd. Zstd is appended (before PrimitiveEncoding) when
87+
// WriteOptions.enableZstd() is true. See WriteOptions.withZstd(boolean) for the tradeoff.
9888

9989
private final WritableByteChannel channel;
10090
private final DType.Struct schema;
10191
private final WriteOptions options;
10292
private final List<Encoding> encodings;
10393
private final EncodingRegistry defaultRegistry;
94+
private final List<Encoding> cascadeCodecs;
95+
private final EncodingRegistry cascadeRegistry;
10496
private final List<SegRef> segs = new ArrayList<>();
10597
private final Map<String, List<ChunkRef>> colChunks = new LinkedHashMap<>();
10698
private final Map<EncodingId, Integer> encodingIdx = new LinkedHashMap<>();
@@ -121,11 +113,28 @@ private VortexWriter(
121113
this.options = options;
122114
this.encodings = encodings;
123115
this.defaultRegistry = EncodingRegistry.of(encodings);
116+
this.cascadeCodecs = buildCascadeCodecs(options);
117+
this.cascadeRegistry = EncodingRegistry.of(this.cascadeCodecs);
124118
for (String name : schema.fieldNames()) {
125119
colChunks.put(name, new ArrayList<>());
126120
}
127121
}
128122

123+
private static List<Encoding> buildCascadeCodecs(WriteOptions options) {
124+
List<Encoding> codecs = new ArrayList<>(List.of(
125+
new DateTimePartsEncoding(),
126+
new ConstantEncoding(),
127+
new AlpEncoding(), new FrameOfReferenceEncoding(), new RunEndEncoding(), new RleEncoding(),
128+
new DictEncoding(), new BitpackedEncoding(),
129+
new VarBinEncoding()));
130+
if (options.enableZstd()) {
131+
codecs.add(new ZstdEncoding());
132+
}
133+
codecs.add(new PrimitiveEncoding());
134+
codecs.add(new BoolEncoding());
135+
return List.copyOf(codecs);
136+
}
137+
129138
public static VortexWriter create(
130139
WritableByteChannel channel, DType.Struct schema, WriteOptions options
131140
) {
@@ -307,8 +316,8 @@ private int writeSegment(DType dtype, Object data) throws IOException {
307316
try (Arena arena = Arena.ofConfined()) {
308317
EncodeResult result;
309318
if (options.allowedCascading() > 0) {
310-
EncodeContext encodeCtx = EncodeContext.ofDepth(options.allowedCascading(), arena, CASCADE_REGISTRY);
311-
CascadingCompressor compressor = new CascadingCompressor(CASCADE_CODECS);
319+
EncodeContext encodeCtx = EncodeContext.ofDepth(options.allowedCascading(), arena, cascadeRegistry);
320+
CascadingCompressor compressor = new CascadingCompressor(cascadeCodecs);
312321
result = compressor.encode(dtype, data, encodeCtx);
313322
} else {
314323
Encoding encoding = findEncoding(dtype);
Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,62 @@
11
package io.github.dfa1.vortex.writer;
22

33
/// Tuning knobs for the Vortex writer.
4+
///
5+
/// @param chunkSize target row count per chunk (default 65 536)
6+
/// @param enableZoneMaps write per-chunk min/max statistics for zone-map pruning
7+
/// @param compressionRatioThreshold minimum compression ratio for an encoding to be accepted (0–1)
8+
/// @param allowedCascading maximum recursive cascade depth; 0 = no cascading
9+
/// @param globalDict build one shared dictionary across all chunks for low-cardinality columns
10+
/// @param enableZstd add Zstandard to the cascade codec list
11+
/// <p>When {@code true}, Zstd competes with structural encodings (ALP, bitpack, etc.)
12+
/// on every chunk and wins when it produces smaller output — typically reducing
13+
/// file size by 10–15% on real-world datasets compared to ALP+bitpack alone.
14+
/// Trade-off: Zstd decompression is ~6× slower than ALP decode;
15+
/// prefer the default ({@code false}) for read-heavy workloads.
416
public record WriteOptions(
517
int chunkSize,
618
boolean enableZoneMaps,
719
double compressionRatioThreshold,
820
int allowedCascading,
9-
boolean globalDict
21+
boolean globalDict,
22+
boolean enableZstd
1023
) {
11-
/// Default options: global dictionary encoding enabled, no cascading compression.
24+
/// Default options: global dictionary encoding enabled, no cascading compression, Zstd disabled.
25+
///
26+
/// @return default {@code WriteOptions}
1227
public static WriteOptions defaults() {
13-
return new WriteOptions(65_536, true, 0.90, 0, true);
28+
return new WriteOptions(65_536, true, 0.90, 0, true, false);
1429
}
1530

1631
/// Enable cascading compression with up to {@code depth} recursive levels.
1732
/// Depth 0 preserves current first-match behaviour.
33+
///
34+
/// @param depth maximum cascade depth
35+
/// @return {@code WriteOptions} with cascading enabled at the given depth
1836
public static WriteOptions cascading(int depth) {
19-
return new WriteOptions(65_536, true, 0.90, depth, true);
37+
return new WriteOptions(65_536, true, 0.90, depth, true, false);
2038
}
2139

2240
/// Returns a copy of these options with global dictionary encoding set to {@code enabled}.
2341
///
2442
/// @param enabled {@code true} to enable global dictionary encoding across chunks
2543
/// @return a new {@code WriteOptions} with the global dict flag updated
2644
public WriteOptions withGlobalDict(boolean enabled) {
27-
return new WriteOptions(chunkSize, enableZoneMaps, compressionRatioThreshold, allowedCascading, enabled);
45+
return new WriteOptions(chunkSize, enableZoneMaps, compressionRatioThreshold, allowedCascading, enabled, enableZstd);
46+
}
47+
48+
/// Returns a copy of these options with Zstandard compression set to {@code enabled}.
49+
///
50+
/// <p>When enabled, Zstd is added to the cascade codec list and competes with structural encodings
51+
/// (ALP, bitpack, FOR, etc.) on every chunk. Zstd typically wins on high-cardinality numeric columns
52+
/// (e.g. {@code fare_amount}, {@code total_amount}), reducing file size by 10–15%.
53+
///
54+
/// <p>Trade-off: Zstd decompression is ~6× slower than ALP reconstruction or bitpack unpack.
55+
/// Use {@code false} (the default) for read-heavy or latency-sensitive workloads.
56+
///
57+
/// @param enabled {@code true} to enable Zstd in the compression cascade
58+
/// @return a new {@code WriteOptions} with the Zstd flag updated
59+
public WriteOptions withZstd(boolean enabled) {
60+
return new WriteOptions(chunkSize, enableZoneMaps, compressionRatioThreshold, allowedCascading, globalDict, enabled);
2861
}
2962
}

0 commit comments

Comments
 (0)