Skip to content

Commit 506d036

Browse files
dfa1claude
andcommitted
perf: vortex.sparse for mixed-validity bitmaps
MaskedEncodingEncoder now tries vortex.sparse (fill = majority value, patches = minority positions) for a mixed-validity bitmap and keeps it over the raw vortex.bool bitmap when smaller. The patch-index array is compressed through a dedicated cascade (vortex.sequence, fastlanes.delta, FoR, bitpack) kept separate from VortexWriter's general per-column cascade codec list, so a clustered or regular null pattern collapses to a handful of bytes instead of a full n/8-byte bitmap. Also fixes SequenceEncodingEncoder.encodeCascade: it had no override and inherited the default that calls encode() directly, which throws when a stratified sample looks arithmetic but the full data isn't. That's a pre-existing gap in the general cascade retry contract (every other encoder returns CascadeStep.notApplicable() instead of throwing) that the new patch-index cascade path exposed. On the 200k-row/10-chunk nullable low-cardinality Utf8 benchmark: 107KB -> 86KB, Java/JNI ratio 1.45x -> 1.17x. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ecd47ea commit 506d036

7 files changed

Lines changed: 211 additions & 16 deletions

File tree

CHANGELOG.md

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

1212
- 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)
1313
- `MaskedEncodingEncoder` now encodes an all-valid or all-invalid validity bitmap as `vortex.constant` instead of a raw per-row bitmap. A nullable column chunk with zero nulls previously still paid a full `n/8`-byte bitmap for no information; an all-valid or all-invalid chunk now costs a few bytes regardless of row count. (internal)
14+
- `MaskedEncodingEncoder` also tries `vortex.sparse` for a mixed-validity bitmap (fill = the majority value, patches = the minority positions) and keeps it over the raw bitmap when smaller — the patch-index array is compressed through a dedicated `vortex.sequence`/`fastlanes.delta`/FoR/bitpack cascade, so a clustered or regular null pattern (e.g. one null every 10 rows) collapses to a handful of bytes instead of a full bitmap. On the 200k-row, 10-chunk nullable low-cardinality Utf8 benchmark this drops the file a further 20% (107 KB → 86 KB) and the gap to the Rust reference from ~1.45× to ~1.17×. Also: `SequenceEncodingEncoder.encodeCascade` no longer lets `encode`'s "not an arithmetic sequence" exception escape when a stratified sample looked arithmetic but the full data wasn't — it now reports the cascade step as not applicable, like every other encoder's contract requires. (internal)
1415

1516
## [0.12.2] — 2026-07-12
1617

TODO.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@
1212
- [ ] Performance tests must be peer-reviewed
1313
- [ ] Run performance tests on other machines (I have access only to Apple M5)
1414
- [ ] **Vector API adoption** — see [ADR-0005](adr/0005-vector-api-adoption.md).
15-
- [ ] **`DType.Bool` has no RLE/Sparse/Constant encoding**`RunEndEncodingEncoder`,
16-
`RleEncodingEncoder`, `SparseEncodingEncoder`, and `ConstantEncodingEncoder` all `accepts()`
17-
only `DType.Primitive`; the sole Bool-capable encoder, `BoolEncodingEncoder`, always emits a raw
18-
1-bit/row bitmap. This caps how small a nullable column's `vortex.masked` validity child can get:
19-
measured at ~75% of the remaining vortex-jni gap (25 KB of 33 KB) on a 200k-row/10-chunk nullable
20-
low-cardinality Utf8 benchmark (`FileSizeComparisonIntegrationTest#nullableLowCardinalityUtf8_multiChunk_globalDict_javaVsJni`,
21-
commit 5fe8b544). Needs end-to-end Bool support (write + read) for at least one of RLE/Sparse —
22-
comparable effort to the nullable-global-dict fix, not a small tweak.
15+
- [ ] **`DType.Bool` still has no RLE/Constant-at-the-`accepts()`-level encoding**
16+
`RunEndEncodingEncoder`/`RleEncodingEncoder`/`ConstantEncodingEncoder` all `accepts()` only
17+
`DType.Primitive`. `MaskedEncodingEncoder` now covers the all-valid/all-invalid case directly
18+
(`vortex.constant`, bypassing `accepts()`) and the mixed-validity case via a dedicated
19+
`SparseEncodingEncoder.encodeBool` path (fill = majority value, patches = minority positions,
20+
index array cascaded through `vortex.sequence`/`fastlanes.delta`/FoR/bitpack) — closing most of
21+
the remaining vortex-jni gap on a clustered/regular null pattern (200k-row/10-chunk nullable
22+
low-cardinality Utf8 benchmark: 1.45× → 1.17×, commits 5fe8b544/ecd47ead + Sparse-for-Bool).
23+
What's left: a **scattered, non-clustered** null pattern (no exploitable index structure) still
24+
costs close to the raw 1-bit/row bitmap — `vortex.runend`/`fastlanes.rle` genuinely support Bool
25+
would help there, but that needs the same `accepts()`/fill-value generalization done for
26+
`SparseEncodingEncoder`, applied to two more encoders (plus their decoders, if not already
27+
Bool-capable like `SparseEncodingDecoder` turned out to be).
2328

2429
## Security
2530

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -529,12 +529,13 @@ void nullableLowCardinalityUtf8_multiChunk_globalDict_javaVsJni(@TempDir Path tm
529529
"[NullableMultiChunkUtf8] %,d rows %d chunks JNI=%,d bytes Java=%,d bytes Java/JNI=%.2fx%n",
530530
n, n / batch, jniSize, javaSize, ratio);
531531

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.
532+
// Then — global dict across chunks, plus vortex.sparse on the masked codes' validity
533+
// (patch indices further compressed via vortex.sequence/fastlanes.delta on the
534+
// clustered/regular null pattern), closes most of the gap the per-chunk path leaves.
535+
// The bound is set from the measured ratio (~1.17x) with headroom.
535536
assertThat(ratio)
536537
.as("nullable low-cardinality Utf8 with global dict across chunks stays near JNI")
537-
.isLessThan(2.5);
538+
.isLessThan(1.5);
538539

539540
// Then — Java file is readable, row count preserved.
540541
var totalRows = new java.util.concurrent.atomic.AtomicLong();

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,14 @@ private static EncodeResult encodeValues(DType nonNullable, Object values, Encod
8383
return pickInner(nonNullable).encode(nonNullable, values, ctx);
8484
}
8585

86-
/// Encodes a masked column's validity bitmap, using `vortex.constant` instead of a raw bitmap
87-
/// when every row shares the same validity (the whole chunk is all-valid or all-invalid) — a
88-
/// common case that otherwise costs a full `n/8`-byte bitmap for no information.
86+
/// Encodes a masked column's validity bitmap.
87+
///
88+
/// Every row sharing the same validity (the whole chunk all-valid or all-invalid) is
89+
/// `vortex.constant` — a common case that otherwise costs a full `n/8`-byte bitmap for no
90+
/// information. Otherwise, with cascade depth available, also tries `vortex.sparse`
91+
/// ([SparseEncodingEncoder#encodeBool]) — a clustered or regular null pattern can compress its
92+
/// patch-index array well below a raw bitmap — and keeps whichever candidate is smaller.
93+
/// Without cascade depth, or when sparse doesn't win, falls back to a raw `vortex.bool` bitmap.
8994
///
9095
/// @param validity per-row validity bitmap
9196
/// @param ctx the encode context
@@ -96,7 +101,20 @@ private static EncodeResult encodeValidity(boolean[] validity, EncodeContext ctx
96101
ProtoScalarValue scalar = ProtoScalarValue.ofBoolValue(value);
97102
return EncodeResult.simple(EncodingId.VORTEX_CONSTANT, MemorySegment.ofArray(scalar.encode()));
98103
}
99-
return new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
104+
EncodeResult raw = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
105+
if (ctx.allowedCascading() <= 0) {
106+
return raw;
107+
}
108+
EncodeResult sparse = SparseEncodingEncoder.encodeBool(validity, ctx);
109+
return totalBytes(sparse) < totalBytes(raw) ? sparse : raw;
110+
}
111+
112+
private static long totalBytes(EncodeResult result) {
113+
long total = 0;
114+
for (MemorySegment buf : result.buffers()) {
115+
total += buf.byteSize();
116+
}
117+
return total;
100118
}
101119

102120
private static boolean isConstantValidity(boolean[] validity) {

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ public boolean accepts(DType dtype) {
2323
return dtype instanceof DType.Primitive;
2424
}
2525

26+
/// Cascade-aware entry point. [#encode] throws when `data` isn't a perfect arithmetic sequence
27+
/// (e.g. a winner selected on a stratified sample that turns out not to hold over the full
28+
/// data) — the cascade's retry contract needs a non-applicable [CascadeStep], not an exception,
29+
/// so failures are caught here and reported that way instead.
30+
@Override
31+
public CascadeStep encodeCascade(DType dtype, Object data, EncodeContext ctx) {
32+
try {
33+
return CascadeStep.terminal(encode(dtype, data, ctx));
34+
} catch (VortexException e) {
35+
return CascadeStep.notApplicable();
36+
}
37+
}
38+
2639
@Override
2740
public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
2841
if (!(dtype instanceof DType.Primitive p)) {

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@
1616
/// Write-only encoder for `vortex.sparse`.
1717
public final class SparseEncodingEncoder implements EncodingEncoder {
1818

19+
/// Candidates for compressing a boolean mask's patch-index array ([#encodeBool]) — a sorted,
20+
/// often-regular sequence (e.g. a periodic null pattern), so `vortex.sequence`/`fastlanes.delta`
21+
/// are tried first. Deliberately separate from [io.github.dfa1.vortex.writer.VortexWriter]'s
22+
/// general per-column cascade codec list, which excludes both: adding them there would change
23+
/// selection for every dense Primitive column, not just this narrow index-compression case.
24+
private static final List<EncodingEncoder> INDEX_CASCADE_CANDIDATES = List.of(
25+
new SequenceEncodingEncoder(),
26+
new DeltaEncodingEncoder(),
27+
new FrameOfReferenceEncodingEncoder(),
28+
new BitpackedEncodingEncoder(),
29+
new PrimitiveEncodingEncoder());
30+
1931
@Override
2032
public EncodingId encodingId() {
2133
return EncodingId.VORTEX_SPARSE;
@@ -184,6 +196,89 @@ private static Object valArr(List<Long> patchBits, PType ptype) {
184196
};
185197
}
186198

199+
/// Encodes a boolean mask as `vortex.sparse`: fill = the majority value, patches = the minority
200+
/// positions. Unlike the numeric path (hardcoded fill = 0, `expectedRatio`-gated) this always
201+
/// builds the result — a two-valued domain has no "wrong" fill to guess, and the patch index
202+
/// array is run through the full [CascadingCompressor] so a clustered or regular null pattern
203+
/// (e.g. `fastlanes.delta` on a periodic run of nulls) can beat a raw 1-bit/row bitmap. Callers
204+
/// compare the returned size against a raw bitmap and keep whichever is smaller.
205+
///
206+
/// @param validity per-row boolean array; must contain at least one `true` and one `false`
207+
/// (an all-same array is cheaper as `vortex.constant` — see [ConstantEncodingEncoder])
208+
/// @param ctx encode context
209+
/// @return the encoded `vortex.sparse` result
210+
static EncodeResult encodeBool(boolean[] validity, EncodeContext ctx) {
211+
int n = validity.length;
212+
int trueCount = 0;
213+
for (boolean b : validity) {
214+
if (b) {
215+
trueCount++;
216+
}
217+
}
218+
boolean fillValue = trueCount * 2 >= n;
219+
int numPatches = fillValue ? n - trueCount : trueCount;
220+
int[] patchIdx = new int[numPatches];
221+
int p = 0;
222+
for (int i = 0; i < n; i++) {
223+
if (validity[i] != fillValue) {
224+
patchIdx[p++] = i;
225+
}
226+
}
227+
boolean[] patchVals = new boolean[numPatches];
228+
java.util.Arrays.fill(patchVals, !fillValue);
229+
230+
PType idxPtype = chooseIdxPtype(n);
231+
Object idxArr = idxArr(patchIdx, idxPtype);
232+
233+
ProtoScalarValue fillScalar = ProtoScalarValue.ofBoolValue(fillValue);
234+
byte[] fillBytes = fillScalar.encode();
235+
MemorySegment fillBuf = ctx.arena().allocate(fillBytes.length);
236+
MemorySegment.copy(MemorySegment.ofArray(fillBytes), 0, fillBuf, 0, fillBytes.length);
237+
238+
DType idxDtype = new DType.Primitive(idxPtype, false);
239+
EncodeResult idxResult = new CascadingCompressor(INDEX_CASCADE_CANDIDATES).encode(idxDtype, idxArr, ctx);
240+
EncodeResult valResult = new BoolEncodingEncoder().encode(DType.BOOL, patchVals, ctx);
241+
242+
List<MemorySegment> buffers = new ArrayList<>();
243+
buffers.add(fillBuf);
244+
buffers.addAll(idxResult.buffers());
245+
int valOffset = 1 + idxResult.buffers().size();
246+
buffers.addAll(valResult.buffers());
247+
248+
EncodeNode idxNode = EncodeNode.remapBufferIndices(idxResult.rootNode(), 1);
249+
EncodeNode valNode = EncodeNode.remapBufferIndices(valResult.rootNode(), valOffset);
250+
251+
ProtoPatchesMetadata patchesMeta = new ProtoPatchesMetadata(
252+
numPatches, 0L, io.github.dfa1.vortex.core.proto.ProtoPType.fromValue(idxPtype.ordinal()),
253+
null, null, null);
254+
byte[] metaBytes = new ProtoSparseMetadata(patchesMeta).encode();
255+
256+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_SPARSE, MemorySegment.ofArray(metaBytes),
257+
new EncodeNode[]{idxNode, valNode}, new int[]{0});
258+
return new EncodeResult(root, List.copyOf(buffers), null, null);
259+
}
260+
261+
private static Object idxArr(int[] patchIdx, PType idxPtype) {
262+
int n = patchIdx.length;
263+
return switch (idxPtype) {
264+
case U8 -> {
265+
byte[] a = new byte[n];
266+
for (int i = 0; i < n; i++) {
267+
a[i] = (byte) patchIdx[i];
268+
}
269+
yield a;
270+
}
271+
case U16 -> {
272+
short[] a = new short[n];
273+
for (int i = 0; i < n; i++) {
274+
a[i] = (short) patchIdx[i];
275+
}
276+
yield a;
277+
}
278+
default -> patchIdx;
279+
};
280+
}
281+
187282
@Override
188283
public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
189284
if (!(dtype instanceof DType.Primitive p)) {

writer/src/test/java/io/github/dfa1/vortex/writer/encode/MaskedEncodingEncoderTest.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.github.dfa1.vortex.reader.decode.ConstantEncodingDecoder;
1616
import io.github.dfa1.vortex.reader.decode.MaskedEncodingDecoder;
1717
import io.github.dfa1.vortex.reader.decode.PrimitiveEncodingDecoder;
18+
import io.github.dfa1.vortex.writer.WriteRegistry;
1819
import org.junit.jupiter.api.Test;
1920

2021
import java.lang.foreign.Arena;
@@ -238,4 +239,65 @@ void mixedValidity_stillEncodesAsRawBitmap() {
238239
assertThat(masked.isValid(1)).isFalse();
239240
assertThat(masked.isValid(2)).isTrue();
240241
}
242+
243+
@Test
244+
void withCascade_periodicNulls_prefersSparseOverRawBitmap() {
245+
// Given — 2000 rows, every 10th null: a clustered/regular pattern (mirrors the real
246+
// low-cardinality-Utf8 benchmark) whose patch-index gaps compress far below a raw bitmap
247+
// once cascaded (e.g. fastlanes.delta on a near-constant stride).
248+
int n = 2_000;
249+
int[] values = new int[n];
250+
boolean[] validity = new boolean[n];
251+
for (int i = 0; i < n; i++) {
252+
values[i] = i;
253+
validity[i] = i % 10 != 0;
254+
}
255+
DType i32Nullable = new DType.Primitive(PType.I32, true);
256+
NullableData data = new NullableData(values, validity);
257+
EncodeContext cascadeCtx = EncodeContext.ofDepth(3, Arena.ofAuto(), WriteRegistry.loadAll());
258+
259+
// When
260+
EncodeResult result = SUT.encode(i32Nullable, data, cascadeCtx);
261+
262+
// Then — the validity child picked vortex.sparse over a raw bitmap
263+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_SPARSE);
264+
265+
// And every value and null position round-trips exactly
266+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, (long) n, i32Nullable, ReadRegistry.loadAll()));
267+
MaskedArray masked = (MaskedArray) decoded;
268+
for (int i = 0; i < n; i++) {
269+
assertThat(masked.isValid(i)).as("row %d", i).isEqualTo(i % 10 != 0);
270+
}
271+
}
272+
273+
@Test
274+
void withCascade_denseRandomNulls_keepsRawBitmap() {
275+
// Given — ~50% nulls with no exploitable structure: sparse patch overhead (index + value
276+
// per minority row) can't beat a raw 1-bit/row bitmap, so the comparison must keep the
277+
// bitmap rather than always preferring sparse.
278+
int n = 2_000;
279+
int[] values = new int[n];
280+
boolean[] validity = new boolean[n];
281+
java.util.Random rng = new java.util.Random(7);
282+
for (int i = 0; i < n; i++) {
283+
values[i] = i;
284+
validity[i] = rng.nextBoolean();
285+
}
286+
DType i32Nullable = new DType.Primitive(PType.I32, true);
287+
NullableData data = new NullableData(values, validity);
288+
EncodeContext cascadeCtx = EncodeContext.ofDepth(3, Arena.ofAuto(), WriteRegistry.loadAll());
289+
290+
// When
291+
EncodeResult result = SUT.encode(i32Nullable, data, cascadeCtx);
292+
293+
// Then
294+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_BOOL);
295+
296+
// And it still round-trips exactly
297+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, (long) n, i32Nullable, ReadRegistry.loadAll()));
298+
MaskedArray masked = (MaskedArray) decoded;
299+
for (int i = 0; i < n; i++) {
300+
assertThat(masked.isValid(i)).as("row %d", i).isEqualTo(validity[i]);
301+
}
302+
}
241303
}

0 commit comments

Comments
 (0)