Skip to content

Commit 5379af6

Browse files
dfa1claude
andcommitted
perf: vortex.runend and Bool-capable vortex.constant
RunEndEncodingEncoder gains an encodeBool path (consecutive equal values collapse into runs) — complements vortex.sparse by winning on long clustered runs of valid/invalid rows instead of a dominant value with scattered flips. MaskedEncodingEncoder now tries both plus a raw bitmap and keeps whichever is smallest. ConstantEncodingEncoder now accepts DType.Bool at the accepts() level (previously Primitive only), so any all-true/all-false dense Bool column can win vortex.constant through the normal per-column cascade, not only via MaskedEncodingEncoder's dedicated check. MaskedEncodingEncoder's validity encoding now delegates to it instead of duplicating the scalar-building logic. Both reader decoders (RunEndEncodingDecoder, ConstantEncodingDecoder) already handled DType.Bool before this change — writer-only fix, same pattern as the vortex.sparse work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 506d036 commit 5379af6

7 files changed

Lines changed: 191 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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)
1414
- `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)
15+
- `ConstantEncodingEncoder` now accepts `DType.Bool` (previously `DType.Primitive` only), so any all-true or all-false dense Bool column — not just a masked/nullable one — can win `vortex.constant` through the normal per-column cascade, not only through `MaskedEncodingEncoder`'s dedicated check. `MaskedEncodingEncoder`'s validity encoding now delegates to it instead of duplicating the scalar-building logic. `RunEndEncodingEncoder` also gains a `vortex.runend` path for Bool: `MaskedEncodingEncoder` tries it alongside `vortex.sparse` and keeps whichever is smallest — `vortex.runend` wins on long clustered runs of valid/invalid rows (regardless of which value dominates), a shape `vortex.sparse`'s per-flip patch cost handles poorly. (internal)
1516

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

TODO.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,15 @@
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` 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).
15+
- [ ] **`fastlanes.rle` still has no `DType.Bool` support**`ConstantEncodingEncoder`,
16+
`SparseEncodingEncoder`, and `RunEndEncodingEncoder` all now handle Bool (constant / scattered
17+
minority / clustered runs respectively — `MaskedEncodingEncoder` tries all three plus a raw
18+
bitmap and keeps the smallest), closing most of the vortex-jni gap on the 200k-row/10-chunk
19+
nullable low-cardinality Utf8 benchmark (4× → 1.17×, commits 5fe8b544/ecd47ead/506d036f + this
20+
session's RunEnd/Constant additions). `RleEncodingEncoder` (`fastlanes.rle`) is the one
21+
remaining Primitive-only holdout; likely low marginal value now that Sparse and RunEnd cover
22+
the scattered/clustered cases, but untested. A truly random (high-entropy) validity pattern has
23+
no exploitable structure for any of these — that's an information-theoretic floor, not a gap.
2824

2925
## Security
3026

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public EncodingId encodingId() {
1818

1919
@Override
2020
public boolean accepts(DType dtype) {
21-
return dtype instanceof DType.Primitive;
21+
return dtype instanceof DType.Primitive || dtype instanceof DType.Bool;
2222
}
2323

2424
@Override
@@ -42,8 +42,11 @@ public Estimate expectedRatio(DType dtype, Object data, ArrayStats stats) {
4242

4343
@Override
4444
public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
45+
if (dtype instanceof DType.Bool) {
46+
return encodeBool((boolean[]) data);
47+
}
4548
if (!(dtype instanceof DType.Primitive p)) {
46-
throw new VortexException(EncodingId.VORTEX_CONSTANT, "encode only supports Primitive dtype, got " + dtype);
49+
throw new VortexException(EncodingId.VORTEX_CONSTANT, "encode only supports Primitive or Bool dtype, got " + dtype);
4750
}
4851
PType ptype = p.ptype();
4952
if (!isConstant(data, ptype)) {
@@ -56,12 +59,40 @@ public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
5659

5760
@Override
5861
public CascadeStep encodeCascade(DType dtype, Object data, EncodeContext encodeCtx) {
62+
if (dtype instanceof DType.Bool bool) {
63+
if (!isConstantBool((boolean[]) data)) {
64+
return CascadeStep.notApplicable();
65+
}
66+
return CascadeStep.terminal(encode(bool, data, encodeCtx));
67+
}
5968
if (!isConstant(data, ((DType.Primitive) dtype).ptype())) {
6069
return CascadeStep.notApplicable();
6170
}
6271
return CascadeStep.terminal(encode(dtype, data, encodeCtx));
6372
}
6473

74+
private static EncodeResult encodeBool(boolean[] data) {
75+
if (!isConstantBool(data)) {
76+
throw new VortexException(EncodingId.VORTEX_CONSTANT, "not a constant array");
77+
}
78+
boolean value = data.length == 0 || data[0];
79+
ProtoScalarValue scalar = ProtoScalarValue.ofBoolValue(value);
80+
return EncodeResult.simple(EncodingId.VORTEX_CONSTANT, MemorySegment.ofArray(scalar.encode()));
81+
}
82+
83+
private static boolean isConstantBool(boolean[] data) {
84+
if (data.length == 0) {
85+
return true;
86+
}
87+
boolean first = data[0];
88+
for (boolean b : data) {
89+
if (b != first) {
90+
return false;
91+
}
92+
}
93+
return true;
94+
}
95+
6596
private static long readFirstRaw(Object data, PType ptype) {
6697
return switch (ptype) {
6798
case I8, U8 -> ((byte[]) data).length > 0 ? ((byte[]) data)[0] : 0L;

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import io.github.dfa1.vortex.core.error.VortexException;
55

66
import io.github.dfa1.vortex.core.model.EncodingId;
7-
import io.github.dfa1.vortex.core.proto.ProtoScalarValue;
87

98
import java.lang.foreign.MemorySegment;
109
import java.util.ArrayList;
@@ -86,27 +85,34 @@ private static EncodeResult encodeValues(DType nonNullable, Object values, Encod
8685
/// Encodes a masked column's validity bitmap.
8786
///
8887
/// 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.
88+
/// `vortex.constant` ([ConstantEncodingEncoder]) — a common case that otherwise costs a full
89+
/// `n/8`-byte bitmap for no information. Otherwise, with cascade depth available, also tries
90+
/// `vortex.sparse` ([SparseEncodingEncoder#encodeBool] — wins on a dominant value with
91+
/// scattered rare flips, its patch-index array compressed further) and `vortex.runend`
92+
/// ([RunEndEncodingEncoder#encodeBool] — wins on long clustered runs of valid/invalid rows,
93+
/// regardless of which value dominates), keeping whichever candidate is smallest. Without
94+
/// cascade depth, or when neither wins, falls back to a raw `vortex.bool` bitmap.
9495
///
9596
/// @param validity per-row validity bitmap
9697
/// @param ctx the encode context
9798
/// @return the encoded validity child
9899
private static EncodeResult encodeValidity(boolean[] validity, EncodeContext ctx) {
99100
if (isConstantValidity(validity)) {
100-
boolean value = validity.length == 0 || validity[0];
101-
ProtoScalarValue scalar = ProtoScalarValue.ofBoolValue(value);
102-
return EncodeResult.simple(EncodingId.VORTEX_CONSTANT, MemorySegment.ofArray(scalar.encode()));
101+
return new ConstantEncodingEncoder().encode(DType.BOOL, validity, ctx);
103102
}
104-
EncodeResult raw = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
103+
EncodeResult best = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
105104
if (ctx.allowedCascading() <= 0) {
106-
return raw;
105+
return best;
107106
}
108107
EncodeResult sparse = SparseEncodingEncoder.encodeBool(validity, ctx);
109-
return totalBytes(sparse) < totalBytes(raw) ? sparse : raw;
108+
if (totalBytes(sparse) < totalBytes(best)) {
109+
best = sparse;
110+
}
111+
EncodeResult runEnd = RunEndEncodingEncoder.encodeBool(validity, ctx);
112+
if (totalBytes(runEnd) < totalBytes(best)) {
113+
best = runEnd;
114+
}
115+
return best;
110116
}
111117

112118
private static long totalBytes(EncodeResult result) {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,59 @@ public Estimate expectedRatio(DType dtype, Object data, ArrayStats stats) {
4949
return Estimate.COMPLETE;
5050
}
5151

52+
/// Encodes a boolean array as `vortex.runend`: consecutive equal values collapse into one run.
53+
/// Unlike [SparseEncodingEncoder#encodeBool] (good for a dominant value with scattered rare
54+
/// flips, at the cost of a patch per flip) this wins on CLUSTERED validity — long consecutive
55+
/// stretches of valid or invalid rows — where every flip costs a run regardless of which value
56+
/// is more frequent. Always builds the result; callers compare against alternatives (raw
57+
/// bitmap, sparse) and keep whichever is smallest.
58+
///
59+
/// @param validity per-row boolean array; must contain at least one `true` and one `false`
60+
/// (an all-same array is cheaper as `vortex.constant`)
61+
/// @param ctx encode context
62+
/// @return the encoded `vortex.runend` result
63+
static EncodeResult encodeBool(boolean[] validity, EncodeContext ctx) {
64+
int n = validity.length;
65+
List<Integer> ends = new ArrayList<>();
66+
List<Boolean> values = new ArrayList<>();
67+
boolean runVal = validity[0];
68+
for (int i = 1; i < n; i++) {
69+
if (validity[i] != runVal) {
70+
ends.add(i);
71+
values.add(runVal);
72+
runVal = validity[i];
73+
}
74+
}
75+
ends.add(n);
76+
values.add(runVal);
77+
78+
int numRuns = ends.size();
79+
MemorySegment endsBuf = ctx.arena().allocate((long) numRuns * 4, 4);
80+
for (int i = 0; i < numRuns; i++) {
81+
endsBuf.setAtIndex(VortexFormat.LE_INT, i, ends.get(i));
82+
}
83+
boolean[] valuesArr = new boolean[numRuns];
84+
for (int i = 0; i < numRuns; i++) {
85+
valuesArr[i] = values.get(i);
86+
}
87+
EncodeResult valuesResult = new BoolEncodingEncoder().encode(DType.BOOL, valuesArr, ctx);
88+
89+
byte[] metaBytes = new ProtoRunEndMetadata(
90+
io.github.dfa1.vortex.core.proto.ProtoPType.fromValue(PType.U32.ordinal()),
91+
numRuns, 0L).encode();
92+
93+
EncodeNode endsNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 0);
94+
EncodeNode valuesNode = EncodeNode.remapBufferIndices(valuesResult.rootNode(), 1);
95+
96+
List<MemorySegment> buffers = new ArrayList<>();
97+
buffers.add(endsBuf);
98+
buffers.addAll(valuesResult.buffers());
99+
100+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_RUNEND, MemorySegment.ofArray(metaBytes),
101+
new EncodeNode[]{endsNode, valuesNode}, new int[0]);
102+
return new EncodeResult(root, List.copyOf(buffers), null, null);
103+
}
104+
52105
@Override
53106
public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
54107
if (!(dtype instanceof DType.Primitive p)) {

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import io.github.dfa1.vortex.core.model.DType;
44
import io.github.dfa1.vortex.core.model.EncodingId;
5+
import io.github.dfa1.vortex.core.error.VortexException;
56
import io.github.dfa1.vortex.core.proto.ProtoNullValue;
67
import io.github.dfa1.vortex.core.proto.ProtoScalarValue;
78
import io.github.dfa1.vortex.reader.array.Array;
89
import io.github.dfa1.vortex.reader.array.ByteArray;
910
import io.github.dfa1.vortex.reader.array.DoubleArray;
1011
import io.github.dfa1.vortex.reader.array.FloatArray;
1112
import io.github.dfa1.vortex.reader.array.IntArray;
13+
import io.github.dfa1.vortex.reader.array.LazyConstantBoolArray;
1214
import io.github.dfa1.vortex.reader.array.LazyConstantIntArray;
1315
import io.github.dfa1.vortex.reader.array.LazyConstantLongArray;
1416
import io.github.dfa1.vortex.reader.array.LongArray;
@@ -31,6 +33,7 @@
3133
import java.util.stream.Stream;
3234

3335
import static org.assertj.core.api.Assertions.assertThat;
36+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3437

3538
/// Property: encode then decode is lossless for constant (all-equal) arrays.
3639
/// Property: decode emits a metadata-only `LazyConstantXxxArray` — no buffer at any rowCount.
@@ -245,6 +248,50 @@ void i8_getByte_broadcastsAcrossEveryIndex() {
245248
}
246249
}
247250

251+
@Nested
252+
class Bool {
253+
254+
@ParameterizedTest
255+
@ValueSource(booleans = {true, false})
256+
void encodeDecode_isLossless(boolean value) {
257+
// Given / When
258+
boolean[] data = {value, value, value};
259+
EncodeResult resultEncoded = ENCODER.encode(DType.BOOL, data, EncodeTestHelper.testCtx());
260+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(resultEncoded, data.length, DType.BOOL, REGISTRY);
261+
Array result = DECODER.decode(ctx);
262+
263+
// Then
264+
assertThat(result.length()).isEqualTo(data.length);
265+
assertThat(result).isInstanceOf(LazyConstantBoolArray.class);
266+
assertThat(((LazyConstantBoolArray) result).value()).isEqualTo(value);
267+
}
268+
269+
@Test
270+
void encode_mixedValues_throws() {
271+
// Given
272+
boolean[] data = {true, false};
273+
274+
// When
275+
// Then
276+
assertThatThrownBy(() -> ENCODER.encode(DType.BOOL, data, EncodeTestHelper.testCtx()))
277+
.isInstanceOf(VortexException.class)
278+
.hasMessageContaining("not a constant array");
279+
}
280+
281+
@Test
282+
void encodeCascade_mixedValues_notApplicable() {
283+
// Given — the cascade contract needs a non-applicable step, not an exception, so a
284+
// sample-selected winner that isn't constant on the full data can be excluded and retried
285+
boolean[] data = {true, false};
286+
287+
// When
288+
CascadeStep step = ENCODER.encodeCascade(DType.BOOL, data, EncodeTestHelper.testCtx());
289+
290+
// Then
291+
assertThat(step.applicable()).isFalse();
292+
}
293+
}
294+
248295
/// Rust can write a constant array whose scalar is null (proto null_value tag).
249296
/// The decoder must return a [NullArray] — not 0 / false (#246).
250297
@Nested

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,34 @@ void withCascade_denseRandomNulls_keepsRawBitmap() {
300300
assertThat(masked.isValid(i)).as("row %d", i).isEqualTo(validity[i]);
301301
}
302302
}
303+
304+
@Test
305+
void withCascade_clusteredNulls_prefersRunEndOverSparseAndRawBitmap() {
306+
// Given — 2000 rows, one contiguous invalid stretch (rows 500-999): a handful of runs,
307+
// where sparse would pay a patch per invalid row but run-end pays only for the two
308+
// boundaries — the shape run-end targets that sparse does not.
309+
int n = 2_000;
310+
int[] values = new int[n];
311+
boolean[] validity = new boolean[n];
312+
for (int i = 0; i < n; i++) {
313+
values[i] = i;
314+
validity[i] = i < 500 || i >= 1_000;
315+
}
316+
DType i32Nullable = new DType.Primitive(PType.I32, true);
317+
NullableData data = new NullableData(values, validity);
318+
EncodeContext cascadeCtx = EncodeContext.ofDepth(3, Arena.ofAuto(), WriteRegistry.loadAll());
319+
320+
// When
321+
EncodeResult result = SUT.encode(i32Nullable, data, cascadeCtx);
322+
323+
// Then — the validity child picked vortex.runend over sparse and a raw bitmap
324+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_RUNEND);
325+
326+
// And every value and null position round-trips exactly
327+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, (long) n, i32Nullable, ReadRegistry.loadAll()));
328+
MaskedArray masked = (MaskedArray) decoded;
329+
for (int i = 0; i < n; i++) {
330+
assertThat(masked.isValid(i)).as("row %d", i).isEqualTo(validity[i]);
331+
}
332+
}
303333
}

0 commit comments

Comments
 (0)