Skip to content

Commit 1dbc67d

Browse files
dfa1claude
andcommitted
fix: nullable columns bypass cascade → raw VarBin 10-47x larger than JNI (#258)
MaskedEncodingEncoder.pickInner encoded the non-null values with a fixed first-match encoder {Primitive, VarBin, FixedSizeList}, so a nullable low-cardinality string column never reached Dict or FSST — producing files 10–47× larger than the Rust reference on string-heavy Raincloud corpus slugs (uci-mushroom 22×, uci-online-retail-ii 25×, bi-tablerosistemapenal 47×). When the EncodeContext carries cascade depth (WriteOptions.cascading(n)), MaskedEncodingEncoder now passes the non-null values through the same CascadingCompressor as dense columns. A denseValues() helper substitutes the empty string for null slots in String[] carriers so Dict/FSST can call getBytes() without NPE — those slots are masked by the validity bitmap and never read. Without cascade depth the fixed-fallback path is unchanged. VortexWriter is also updated to hand masked-column override encoders the cascade registry and depth, not a depth-0 context. Results after fix (re-encoded from Raincloud parquet oracle): uci-mushroom: 22× → 3.1× uci-online-retail-ii: 25× → 3.1× bi-tablerosistemapenal: 47× → 6.3× The remaining gap (~2–6×) is Rust's global per-column dict + tighter chunk layout and deeper FSST training — a separate concern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8b4e0f7 commit 1dbc67d

5 files changed

Lines changed: 143 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to **vortex-java** are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- Nullable Utf8/Binary columns are now compressed through the full cascade instead of stored as raw `vortex.varbin`. `MaskedEncodingEncoder` previously encoded its non-null values with a fixed first-match encoder (primitive/varbin), so a nullable low-cardinality string column never reached Dict or FSST — producing files 10–47× larger than the Rust reference on the string-heavy Raincloud corpus (e.g. `uci-mushroom` 22×, `uci-online-retail-ii` 25×). The masked values now run through the same `CascadingCompressor` as dense columns, dropping those ratios to ~2–3×. ([#258](https://github.com/dfa1/vortex-java/issues/258))
13+
814
## [0.12.2] — 2026-07-10
915

1016
**Registry cleanup and schema fidelity** are the two themes of this release. `ReadRegistry` and

docs/compatibility.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Per-slug status lives in `integration/src/test/resources/raincloud/expected-stat
9595
| `vortex.listview` | `ListViewEncodingDecoder` | `ListViewEncodingEncoder` ||| |
9696
| `vortex.fixed_size_list` | `FixedSizeListEncodingDecoder` | `FixedSizeListEncodingEncoder` ||| |
9797
| `vortex.zstd` | `ZstdEncodingDecoder` | `ZstdEncodingEncoder` ||| Primitive, Utf8, Binary |
98-
| `vortex.masked` | `MaskedEncodingDecoder` | `MaskedEncodingEncoder` ||| NullableData carrier; inner picks Primitive / VarBin / FixedSizeList |
98+
| `vortex.masked` | `MaskedEncodingDecoder` | `MaskedEncodingEncoder` ||| NullableData carrier; inner values cascade (Dict/FSST/ALP/…) when cascading is enabled, else first-match Primitive / VarBin / FixedSizeList |
9999
| `vortex.decimal` | `DecimalEncodingDecoder` | `DecimalEncodingEncoder` ||| |
100100
| `vortex.decimal_byte_parts` | `DecimalBytePartsEncodingDecoder`| `DecimalBytePartsEncodingEncoder`||| |
101101
| `vortex.datetimeparts` | `DateTimePartsEncodingDecoder` | `DateTimePartsEncodingEncoder` ||| |

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,84 @@ private static void comparePcoI64(Path dir, String name, long[] data,
342342
assertThat(jniSize).as("JNI pco file for pattern '%s' must be non-empty", name).isGreaterThan(0);
343343
}
344344

345+
@Test
346+
void nullableLowCardinalityUtf8_javaVsJni(@TempDir Path tmp) throws IOException {
347+
// Given — 50k rows of a nullable low-cardinality Utf8 column (8 distinct short strings,
348+
// ~10% nulls), mirroring the Raincloud string-heavy corpus (e.g. uci-mushroom). Before
349+
// #258 the masked (nullable) path stored the values as raw VarBin — never reaching the
350+
// cascade — so the Java file ran 10-47x larger than Rust. Dict must now win on the inner
351+
// values, keeping the file small.
352+
int n = 50_000;
353+
String[] categories = {"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"};
354+
String[] data = new String[n];
355+
java.util.Random rng = new java.util.Random(42);
356+
for (int i = 0; i < n; i++) {
357+
// Every 10th row is null — exercises the vortex.masked validity wrapper.
358+
data[i] = (i % 10 == 0) ? null : categories[rng.nextInt(categories.length)];
359+
}
360+
DType.Struct javaSchema = new DType.Struct(
361+
List.of(ColumnName.of("s")), List.of(DType.UTF8.withNullable(true)), false);
362+
Schema jniSchema = new Schema(List.of(
363+
Field.nullable("s", new ArrowType.Utf8())));
364+
365+
// When
366+
Path javaFile = tmp.resolve("nullable-lowcard-java.vtx");
367+
try (FileChannel ch = FileChannel.open(javaFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
368+
VortexWriter writer = VortexWriter.create(
369+
ch, javaSchema, WriteOptions.cascading(3).withGlobalDict(false))) {
370+
writer.writeChunk(Map.of(ColumnName.of("s"), data));
371+
}
372+
373+
Path jniFile = tmp.resolve("nullable-lowcard-jni.vtx");
374+
String jniUri = jniFile.toAbsolutePath().toUri().toString();
375+
try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create(
376+
SESSION, jniUri, jniSchema, new HashMap<>(), ALLOCATOR);
377+
VectorSchemaRoot root = VectorSchemaRoot.create(jniSchema, ALLOCATOR)) {
378+
VarCharVector vec = (VarCharVector) root.getVector("s");
379+
vec.allocateNew();
380+
for (int i = 0; i < n; i++) {
381+
if (data[i] == null) {
382+
vec.setNull(i);
383+
} else {
384+
vec.setSafe(i, data[i].getBytes(StandardCharsets.UTF_8));
385+
}
386+
}
387+
root.setRowCount(n);
388+
try (ArrowArray arr = ArrowArray.allocateNew(ALLOCATOR);
389+
ArrowSchema schema = ArrowSchema.allocateNew(ALLOCATOR)) {
390+
Data.exportVectorSchemaRoot(ALLOCATOR, root, null, arr, schema);
391+
writer.writeBatch(arr.memoryAddress(), schema.memoryAddress());
392+
}
393+
}
394+
395+
long javaSize = Files.size(javaFile);
396+
long jniSize = Files.size(jniFile);
397+
long rawBytes = (long) n * 5;
398+
System.out.printf(
399+
"[NullableLowCardUtf8] %,d rows raw=%,d bytes JNI=%,d bytes Java=%,d bytes Java/JNI=%.2fx Java/raw=%.2fx%n",
400+
n, rawBytes, jniSize, javaSize,
401+
(double) javaSize / jniSize, (double) javaSize / rawBytes);
402+
403+
// Then — Java stays well under raw storage: the cascade dict-encodes the masked values
404+
// instead of falling through to raw VarBin (the #258 regression, which ran ~20x raw).
405+
assertThat(javaSize).as("dict-encoded masked values must beat raw VarBin storage")
406+
.isLessThan(rawBytes);
407+
408+
// Then — Java within 4x of JNI. Rust's per-column dict + tighter chunk layout is still
409+
// smaller; the guard's purpose is to catch a regression back to the raw-VarBin masked path.
410+
assertThat((double) javaSize / jniSize)
411+
.as("nullable low-cardinality Utf8 must not regress to the raw-VarBin masked path")
412+
.isLessThan(4.0);
413+
414+
// Then — Java file is readable, row count and null positions preserved
415+
var totalRows = new java.util.concurrent.atomic.AtomicLong();
416+
try (VortexReader reader = VortexReader.open(javaFile, ReadRegistry.loadAll());
417+
var iter = reader.scan(io.github.dfa1.vortex.reader.ScanOptions.columns("s"))) {
418+
iter.forEachRemaining(c -> totalRows.addAndGet(c.column("s").length()));
419+
}
420+
assertThat(totalRows.get()).isEqualTo(n);
421+
}
422+
345423
@Test
346424
void highCardinalityUtf8_javaVsJni(@TempDir Path tmp) throws IOException {
347425
// Given — 50k all-distinct short strings. Dict would emit 2-byte codes per row

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,14 @@ private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverr
558558
try (Arena arena = Arena.ofConfined()) {
559559
EncodeResult result;
560560
if (encodingOverride != null) {
561-
EncodeContext encodeCtx = EncodeContext.of(arena, defaultRegistry);
561+
// Give overrides the cascade registry + depth when cascading is enabled, so
562+
// wrapping encoders (notably MaskedEncodingEncoder for nullable columns) can
563+
// compress their inner values through the full CascadingCompressor rather than a
564+
// fixed first-match encoder. Without cascading, a depth-0 context is passed and the
565+
// override behaves as before.
566+
EncodeContext encodeCtx = options.allowedCascading() > 0
567+
? EncodeContext.ofDepth(options.allowedCascading(), arena, cascadeRegistry)
568+
: EncodeContext.of(arena, defaultRegistry);
562569
result = encodingOverride.encode(dtype, data, encodeCtx);
563570
} else if (options.allowedCascading() > 0) {
564571
EncodeContext encodeCtx = EncodeContext.ofDepth(options.allowedCascading(), arena, cascadeRegistry);

writer/src/main/java/io/github/dfa1/vortex/writer/encode/MaskedEncodingEncoder.java

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111

1212
/// Write-only encoder for `vortex.masked`. Wraps the payload encode in a values + validity
1313
/// pair driven by a [NullableData] carrier.
14+
///
15+
/// When the context carries cascade depth (positive [EncodeContext#allowedCascading]) the inner
16+
/// non-nullable values are compressed through the full [CascadingCompressor] built from the
17+
/// registry, so nullable columns get the same Dict/FSST/ALP/bitpack selection as dense columns.
18+
/// Without cascade depth the values fall back to a fixed first-match encoder
19+
/// (primitive / varbin / fixed-size-list).
1420
public final class MaskedEncodingEncoder implements EncodingEncoder {
1521

1622
private static final List<EncodingEncoder> INNER_FALLBACK = List.of(
@@ -35,8 +41,7 @@ public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
3541
"expected NullableData, got " + (data == null ? "null" : data.getClass().getName()));
3642
}
3743
DType nonNullable = dtype.withNullable(false);
38-
EncodingEncoder inner = pickInner(nonNullable);
39-
EncodeResult valuesResult = inner.encode(nonNullable, values, ctx);
44+
EncodeResult valuesResult = encodeValues(nonNullable, values, ctx);
4045
EncodeResult validityResult = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
4146

4247
int valuesBufCount = valuesResult.buffers().size();
@@ -54,6 +59,49 @@ public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
5459
return new EncodeResult(root, buffers, valuesResult.statsMin(), valuesResult.statsMax());
5560
}
5661

62+
/// Encodes the non-null values of a masked column.
63+
///
64+
/// With cascade depth available, runs the full [CascadingCompressor] over the registry's
65+
/// encoders so low-cardinality strings pick Dict, high-cardinality strings pick FSST, and
66+
/// numeric values pick ALP/FOR/bitpacking — the same selection dense columns receive. Without
67+
/// cascade depth, falls back to the fixed first-match encoder. Either way [MaskedEncodingEncoder]
68+
/// never selects itself: its [#accepts] returns `false`, so the compressor cannot recurse into it.
69+
private static EncodeResult encodeValues(DType nonNullable, Object values, EncodeContext ctx) {
70+
if (ctx.allowedCascading() > 0) {
71+
// The NullableData Utf8/Binary carrier keeps null elements in the String[] at null
72+
// positions (validity masks them). Dict/FSST call getBytes() on every element, so
73+
// substitute the empty string for nulls first — matching VarBinEncodingEncoder, which
74+
// encodes a null as a zero-length slot. Those slots are never read: the enclosing
75+
// vortex.masked validity bitmap marks the rows null.
76+
Object dense = denseValues(values);
77+
List<EncodingEncoder> candidates =
78+
List.copyOf(ctx.registry().encoderMap().values());
79+
return new CascadingCompressor(candidates).encode(nonNullable, dense, ctx);
80+
}
81+
return pickInner(nonNullable).encode(nonNullable, values, ctx);
82+
}
83+
84+
/// Returns `values` unchanged, except a `String[]` with null elements is copied with each null
85+
/// replaced by the empty string so cascade encoders (Dict, FSST) can call `getBytes()` safely.
86+
///
87+
/// @param values the non-nullable values carrier extracted from the [NullableData]
88+
/// @return the same array, or a null-free copy when it is a `String[]` containing nulls
89+
private static Object denseValues(Object values) {
90+
if (!(values instanceof String[] strings)) {
91+
return values;
92+
}
93+
String[] out = null;
94+
for (int i = 0; i < strings.length; i++) {
95+
if (strings[i] == null) {
96+
if (out == null) {
97+
out = strings.clone();
98+
}
99+
out[i] = "";
100+
}
101+
}
102+
return out != null ? out : strings;
103+
}
104+
57105
private static EncodingEncoder pickInner(DType nonNullable) {
58106
for (EncodingEncoder e : INNER_FALLBACK) {
59107
if (e.accepts(nonNullable)) {

0 commit comments

Comments
 (0)