Skip to content

Commit 5fe8b54

Browse files
dfa1claude
andcommitted
fix: admit nullable columns to global dict
Nullable low-cardinality columns previously bypassed the writer's shared global dictionary, re-emitting a per-chunk vortex.dict for every chunk instead of one shared dict for the whole file. Nullable primitive/Utf8 columns now pass the same cardinality/ratio gate (skipping null slots) and their per-chunk codes are wrapped in vortex.masked. Also fixes two reader gaps this surfaced: DictLayoutDecoder's Utf8/VarBin branch didn't unwrap masked codes/pool (only the primitive branch did), and ChunkedLayoutDecoder silently dropped per-chunk validity when concatenating masked chunks — a pre-existing bug for any nullable column spanning multiple chunks through that decode path. 200k-row nullable low-card Utf8 test: 232KB -> 107KB (54% smaller), Java/JNI ratio 4x -> 1.45x. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8ec17cb commit 5fe8b54

8 files changed

Lines changed: 541 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Nullable low-cardinality columns now use the shared global dictionary across chunks instead of re-emitting a per-chunk dictionary. The writer previously excluded any nullable column from global-dict candidacy, so a nullable low-cardinality string/numeric column (extremely common in real data — most Raincloud categorical columns have nulls) re-wrote its dictionary in every chunk. Nullable columns now run the same cardinality/ratio gate against their non-null values and emit one shared `vortex.dict` layout with per-chunk masked codes; the reader combines codes-side and pool-side validity per row. On a 200k-row, 10-chunk nullable low-cardinality Utf8 column this shrinks the file 54% (232 KB → 107 KB) and closes the gap to the Rust reference from ~4× to ~1.5×. (internal)
13+
1014
## [0.12.2] — 2026-07-12
1115

1216
**Registry cleanup, schema fidelity, and list-column support** are the three themes of this

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,146 @@ void nullableLowCardinalityUtf8_javaVsJni(@TempDir Path tmp) throws IOException
420420
assertThat(totalRows.get()).isEqualTo(n);
421421
}
422422

423+
@Test
424+
void globalDict_nullableMultiChunkUtf8_smallerThanPerChunkDict(@TempDir Path tmp) throws IOException {
425+
// Given — a NULLABLE low-cardinality Utf8 column (8 categories, ~10% nulls) spanning several
426+
// chunks. Per-chunk-dict mode re-emits the same 8-string dictionary in every chunk; global
427+
// mode emits it once, sharing per-chunk masked codes. This is the direct regression guard for
428+
// admitting nullable columns to the global dict.
429+
int rows = 200_000;
430+
int batch = 20_000;
431+
String[] categories = {"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"};
432+
java.util.Random rng = new java.util.Random(42);
433+
String[][] chunks = new String[rows / batch][];
434+
for (int c = 0; c < chunks.length; c++) {
435+
String[] chunk = new String[batch];
436+
for (int i = 0; i < batch; i++) {
437+
int r = c * batch + i;
438+
chunk[i] = (r % 10 == 0) ? null : categories[rng.nextInt(categories.length)];
439+
}
440+
chunks[c] = chunk;
441+
}
442+
DType.Struct schema = new DType.Struct(
443+
List.of(ColumnName.of("s")), List.of(DType.UTF8.withNullable(true)), false);
444+
445+
// When
446+
Path globalDictFile = writeNullableUtf8Chunks(tmp, "gdict-on.vtx", schema, chunks, true);
447+
Path perChunkDictFile = writeNullableUtf8Chunks(tmp, "gdict-off.vtx", schema, chunks, false);
448+
long globalDictSize = Files.size(globalDictFile);
449+
long perChunkDictSize = Files.size(perChunkDictFile);
450+
451+
// Then — report
452+
System.out.printf(
453+
"[GlobalDictNullableUtf8] %,d rows %d chunks globalDict=%,d bytes perChunkDict=%,d bytes saving=%.2f%%%n",
454+
rows, chunks.length, globalDictSize, perChunkDictSize,
455+
100.0 * (perChunkDictSize - globalDictSize) / perChunkDictSize);
456+
457+
// Then — global dict is strictly smaller than the per-chunk-dict baseline for identical
458+
// nullable data.
459+
assertThat(globalDictSize).isLessThan(perChunkDictSize);
460+
461+
// Then — global dict file readable, row count matches.
462+
var totalRows = new java.util.concurrent.atomic.AtomicLong();
463+
try (VortexReader reader = VortexReader.open(globalDictFile, ReadRegistry.loadAll());
464+
var iter = reader.scan(io.github.dfa1.vortex.reader.ScanOptions.columns("s"))) {
465+
iter.forEachRemaining(c -> totalRows.addAndGet(c.column("s").length()));
466+
}
467+
assertThat(totalRows.get()).isEqualTo(rows);
468+
}
469+
470+
@Test
471+
void nullableLowCardinalityUtf8_multiChunk_globalDict_javaVsJni(@TempDir Path tmp) throws IOException {
472+
// Given — the same nullable low-cardinality column as nullableLowCardinalityUtf8_javaVsJni,
473+
// but written across MULTIPLE chunks with global dict ENABLED (default WriteOptions). Global
474+
// dict only pays off across chunks (a single chunk sees no benefit over per-chunk dict), so
475+
// this exercises the shared-dictionary path the fix adds and measures its ratio against JNI.
476+
int n = 200_000;
477+
int batch = 20_000;
478+
String[] categories = {"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"};
479+
String[] data = new String[n];
480+
java.util.Random rng = new java.util.Random(42);
481+
for (int i = 0; i < n; i++) {
482+
data[i] = (i % 10 == 0) ? null : categories[rng.nextInt(categories.length)];
483+
}
484+
DType.Struct javaSchema = new DType.Struct(
485+
List.of(ColumnName.of("s")), List.of(DType.UTF8.withNullable(true)), false);
486+
Schema jniSchema = new Schema(List.of(
487+
Field.nullable("s", new ArrowType.Utf8())));
488+
489+
// When — Java: multiple chunks, global dict on (default cascading(3)).
490+
Path javaFile = tmp.resolve("nullable-multichunk-java.vtx");
491+
try (FileChannel ch = FileChannel.open(javaFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
492+
VortexWriter writer = VortexWriter.create(ch, javaSchema, WriteOptions.cascading(3))) {
493+
for (int off = 0; off < n; off += batch) {
494+
String[] chunk = new String[batch];
495+
System.arraycopy(data, off, chunk, 0, batch);
496+
writer.writeChunk(Map.of(ColumnName.of("s"), chunk));
497+
}
498+
}
499+
500+
Path jniFile = tmp.resolve("nullable-multichunk-jni.vtx");
501+
String jniUri = jniFile.toAbsolutePath().toUri().toString();
502+
try (dev.vortex.api.VortexWriter writer = dev.vortex.api.VortexWriter.create(
503+
SESSION, jniUri, jniSchema, new HashMap<>(), ALLOCATOR);
504+
VectorSchemaRoot root = VectorSchemaRoot.create(jniSchema, ALLOCATOR)) {
505+
for (int off = 0; off < n; off += batch) {
506+
VarCharVector vec = (VarCharVector) root.getVector("s");
507+
vec.reset();
508+
vec.allocateNew();
509+
for (int i = 0; i < batch; i++) {
510+
if (data[off + i] == null) {
511+
vec.setNull(i);
512+
} else {
513+
vec.setSafe(i, data[off + i].getBytes(StandardCharsets.UTF_8));
514+
}
515+
}
516+
root.setRowCount(batch);
517+
try (ArrowArray arr = ArrowArray.allocateNew(ALLOCATOR);
518+
ArrowSchema schema = ArrowSchema.allocateNew(ALLOCATOR)) {
519+
Data.exportVectorSchemaRoot(ALLOCATOR, root, null, arr, schema);
520+
writer.writeBatch(arr.memoryAddress(), schema.memoryAddress());
521+
}
522+
}
523+
}
524+
525+
long javaSize = Files.size(javaFile);
526+
long jniSize = Files.size(jniFile);
527+
double ratio = (double) javaSize / jniSize;
528+
System.out.printf(
529+
"[NullableMultiChunkUtf8] %,d rows %d chunks JNI=%,d bytes Java=%,d bytes Java/JNI=%.2fx%n",
530+
n, n / batch, jniSize, javaSize, ratio);
531+
532+
// Then — global dict across chunks closes most of the gap the per-chunk path leaves. The
533+
// bound is set from the measured ratio with headroom; it is tighter than the 4.0x guard on
534+
// the single-chunk global-dict-disabled test.
535+
assertThat(ratio)
536+
.as("nullable low-cardinality Utf8 with global dict across chunks stays near JNI")
537+
.isLessThan(2.5);
538+
539+
// Then — Java file is readable, row count preserved.
540+
var totalRows = new java.util.concurrent.atomic.AtomicLong();
541+
try (VortexReader reader = VortexReader.open(javaFile, ReadRegistry.loadAll());
542+
var iter = reader.scan(io.github.dfa1.vortex.reader.ScanOptions.columns("s"))) {
543+
iter.forEachRemaining(c -> totalRows.addAndGet(c.column("s").length()));
544+
}
545+
assertThat(totalRows.get()).isEqualTo(n);
546+
}
547+
548+
/// Writes a nullable Utf8 column across the given per-chunk arrays, with the global-dict option
549+
/// toggled. Returns the file path.
550+
private static Path writeNullableUtf8Chunks(Path dir, String filename, DType.Struct schema,
551+
String[][] chunks, boolean globalDict) throws IOException {
552+
Path file = dir.resolve(filename);
553+
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
554+
VortexWriter writer = VortexWriter.create(
555+
ch, schema, WriteOptions.cascading(3).withGlobalDict(globalDict))) {
556+
for (String[] chunk : chunks) {
557+
writer.writeChunk(Map.of(ColumnName.of("s"), chunk));
558+
}
559+
}
560+
return file;
561+
}
562+
423563
@Test
424564
void highCardinalityUtf8_javaVsJni(@TempDir Path tmp) throws IOException {
425565
// Given — 50k all-distinct short strings. Dict would emit 2-byte codes per row

reader/src/main/java/io/github/dfa1/vortex/reader/layout/ChunkedLayoutDecoder.java

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@
1313
import io.github.dfa1.vortex.reader.array.ChunkedIntArray;
1414
import io.github.dfa1.vortex.reader.array.ChunkedLongArray;
1515
import io.github.dfa1.vortex.reader.array.ChunkedShortArray;
16+
import io.github.dfa1.vortex.reader.array.BoolArray;
17+
import io.github.dfa1.vortex.reader.array.MaskedArray;
18+
import io.github.dfa1.vortex.reader.array.MaterializedBoolArray;
1619
import io.github.dfa1.vortex.reader.array.VarBinArray;
1720

21+
import java.lang.foreign.MemorySegment;
22+
import java.lang.foreign.SegmentAllocator;
1823
import java.lang.foreign.ValueLayout;
1924
import java.util.ArrayList;
2025
import java.util.List;
@@ -36,6 +41,46 @@ public Array decode(LayoutDecodeContext ctx, Layout layout, DType dtype) {
3641
return decodeChunkedLayout(ctx, flats, dtype, layout.rowCount());
3742
}
3843

44+
/// Concatenates the per-chunk validity of masked chunks into one row-level bitmap, or returns
45+
/// `null` when no chunk is nullable. Unmasked chunks (and masked chunks with a `null` validity,
46+
/// which mean all-valid) contribute all-valid rows. This preserves nullability that the
47+
/// `ChunkedXxxArray.of` constructors deliberately drop when flattening masked chunks — needed so
48+
/// a nullable chunked column (e.g. the codes of a nullable global-dict column) keeps its nulls.
49+
///
50+
/// @param chunkArrays the decoded per-chunk arrays, in row order
51+
/// @param totalRows the total logical row count across all chunks
52+
/// @param arena allocator for the combined bitmap
53+
/// @return a bit-packed row-validity [BoolArray] of `totalRows` bits, or `null` if all valid
54+
private static BoolArray combineChunkValidity(List<Array> chunkArrays, long totalRows,
55+
SegmentAllocator arena) {
56+
boolean anyMasked = false;
57+
for (Array chunk : chunkArrays) {
58+
if (chunk instanceof MaskedArray m && m.validity() != null) {
59+
anyMasked = true;
60+
break;
61+
}
62+
}
63+
if (!anyMasked) {
64+
return null;
65+
}
66+
MemorySegment bits = arena.allocate((totalRows + 7) >>> 3);
67+
long row = 0;
68+
for (Array chunk : chunkArrays) {
69+
long chunkLen = chunk.length();
70+
BoolArray validity = chunk instanceof MaskedArray m ? m.validity() : null;
71+
for (long i = 0; i < chunkLen; i++) {
72+
if (validity == null || validity.getBoolean(i)) {
73+
long globalRow = row + i;
74+
long byteIdx = globalRow >>> 3;
75+
byte cur = bits.get(ValueLayout.JAVA_BYTE, byteIdx);
76+
bits.set(ValueLayout.JAVA_BYTE, byteIdx, (byte) ((cur & 0xff) | (1 << (globalRow & 7))));
77+
}
78+
}
79+
row += chunkLen;
80+
}
81+
return new MaterializedBoolArray(DType.BOOL, totalRows, bits.asReadOnly());
82+
}
83+
3984
/// Flattens a layout subtree into its ordered flat (and dict) leaves. A private copy of
4085
/// `ScanIterator.collectFlats`: the scan keeps its own for chunk-shape planning, while the
4186
/// chunked decoder needs the same flattening to build the leaf array list.
@@ -80,21 +125,28 @@ private static Array decodeChunkedLayout(LayoutDecodeContext ctx, List<Layout> f
80125
// a leaf's layout id must be honored under a chunked parent too.
81126
chunkArrays.add(ctx.decodeChild(flat, dtype));
82127
}
128+
Array data;
83129
if (dtype instanceof DType.Bool) {
84-
return ChunkedBoolArray.of(dtype, totalRows, chunkArrays);
85-
}
86-
if (dtype instanceof DType.Utf8 || dtype instanceof DType.Binary) {
87-
return VarBinArray.ChunkedMode.of(dtype, totalRows, chunkArrays);
130+
data = ChunkedBoolArray.of(dtype, totalRows, chunkArrays);
131+
} else if (dtype instanceof DType.Utf8 || dtype instanceof DType.Binary) {
132+
data = VarBinArray.ChunkedMode.of(dtype, totalRows, chunkArrays);
133+
} else {
134+
PType ptype = ((DType.Primitive) dtype).ptype();
135+
data = switch (ptype) {
136+
case I64, U64 -> ChunkedLongArray.of(dtype, totalRows, chunkArrays);
137+
case I32, U32 -> ChunkedIntArray.of(dtype, totalRows, chunkArrays);
138+
case F64 -> ChunkedDoubleArray.of(dtype, totalRows, chunkArrays);
139+
case F32 -> ChunkedFloatArray.of(dtype, totalRows, chunkArrays);
140+
case I16, U16 -> ChunkedShortArray.of(dtype, totalRows, chunkArrays);
141+
case I8, U8 -> ChunkedByteArray.of(dtype, totalRows, chunkArrays);
142+
default -> throw new VortexException("unsupported ptype for chunked layout: " + ptype);
143+
};
88144
}
89-
PType ptype = ((DType.Primitive) dtype).ptype();
90-
return switch (ptype) {
91-
case I64, U64 -> ChunkedLongArray.of(dtype, totalRows, chunkArrays);
92-
case I32, U32 -> ChunkedIntArray.of(dtype, totalRows, chunkArrays);
93-
case F64 -> ChunkedDoubleArray.of(dtype, totalRows, chunkArrays);
94-
case F32 -> ChunkedFloatArray.of(dtype, totalRows, chunkArrays);
95-
case I16, U16 -> ChunkedShortArray.of(dtype, totalRows, chunkArrays);
96-
case I8, U8 -> ChunkedByteArray.of(dtype, totalRows, chunkArrays);
97-
default -> throw new VortexException("unsupported ptype for chunked layout: " + ptype);
98-
};
145+
// The ChunkedXxxArray.of constructors flatten masked chunks to their inner data, dropping
146+
// validity. Recover per-chunk nullability by concatenating chunk validity into one bitmap and
147+
// re-wrapping — otherwise a nullable chunked column (e.g. nullable global-dict codes) loses
148+
// its nulls when it spans more than one chunk.
149+
BoolArray validity = combineChunkValidity(chunkArrays, totalRows, ctx.arena());
150+
return validity != null ? new MaskedArray(data, validity) : data;
99151
}
100152
}

reader/src/main/java/io/github/dfa1/vortex/reader/layout/DictLayoutDecoder.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,38 @@ public Array decode(LayoutDecodeContext ctx, Layout dictLayout, DType dtype) {
6565

6666
// VarBin (string) dict: VarBinArray is a sealed interface; ofDict returns the
6767
// lazy DictMode record (no eager expansion into per-row offsets/bytes).
68-
if (values instanceof VarBinArray.OffsetMode vb) {
68+
// Unwrap a masked (nullable) codes/values child so the string expansion sees the raw
69+
// payload; the row-level validity is re-applied by wrapping the result below. This mirrors
70+
// the primitive path (buildLazyDictPrimitive) and is the shape a nullable global-dict Utf8
71+
// column produces (masked codes + non-nullable pool).
72+
BoolArray poolValidity = values instanceof MaskedArray mv ? mv.validity() : null;
73+
Array valuesData = values instanceof MaskedArray mv ? mv.inner() : values;
74+
BoolArray codesValidity = codes instanceof MaskedArray mc ? mc.validity() : null;
75+
Array codesData = codes instanceof MaskedArray mc ? mc.inner() : codes;
76+
if (valuesData instanceof VarBinArray.OffsetMode vb) {
6977
// Zip-bomb guard: read the codes as a segment so we can validate the buffer
7078
// before allocating the expansion output. For direct-mapped encodings (e.g.
7179
// vortex.primitive), the codes buffer is mmap-bounded and can be much smaller
7280
// than the claimed rowCount. Full-decode encodings (e.g. bitpacked) already
7381
// wrote n * elemBytes to the arena during decodeChild above, so their buffer
7482
// matches n.
75-
MemorySegment codesSeg = codes.materialize(arena);
83+
MemorySegment codesSeg = codesData.materialize(arena);
7684
long bufferCodes = codesSeg.byteSize() / codesPType.byteSize();
7785
if (bufferCodes < n) {
7886
throw new VortexException(EncodingId.VORTEX_DICT,
7987
"dict codes: layout row_count=" + n + " exceeds buffer capacity=" + bufferCodes);
8088
}
8189
MemorySegment valOffsets = vb.offsetsSegment();
8290
PType valOffPType = vb.offsetsPtype();
83-
return VarBinArray.ofDict(dtype, n, vb.bytesSegment(), valOffsets, valOffPType,
91+
Array dict = VarBinArray.ofDict(dtype, n, vb.bytesSegment(), valOffsets, valOffPType,
8492
codesSeg, codesPType);
93+
if (poolValidity == null && codesValidity == null) {
94+
return dict;
95+
}
96+
if (poolValidity == null) {
97+
return new MaskedArray(dict, codesValidity);
98+
}
99+
return new MaskedArray(dict, gatherRowValidity(codesData, codesValidity, poolValidity, n, arena));
85100
}
86101
if (dtype instanceof DType.Primitive pDtype) {
87102
// Zip-bomb guard (lazy path): the codes Array has already been decoded above;

0 commit comments

Comments
 (0)