Skip to content

Commit fe6f132

Browse files
dfa1claude
andcommitted
perf: fastlanes.rle for Bool, closing encoding parity
RleEncodingEncoder/RleEncodingDecoder gain Bool support: the same FastLanes 1024-row chunked layout as the numeric path, with a vortex.bool-encoded values pool (at most 2 distinct values per chunk) instead of a ptype-width one. Unlike vortex.sparse/ vortex.runend (reader was already Bool-capable), fastlanes.rle needed both sides: a new LazyRleBoolArray plus a decode branch, since RleEncodingDecoder previously rejected any non-Primitive dtype outright. MaskedEncodingEncoder now tries all four validity encodings (vortex.constant, vortex.sparse, vortex.runend, fastlanes.rle) and keeps whichever is smallest. DType.Bool now has every general-purpose encoding family DType.Primitive has. Drops the now-closed TODO.md gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5379af6 commit fe6f132

7 files changed

Lines changed: 234 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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)
1515
- `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)
16+
- `RleEncodingEncoder`/`RleEncodingDecoder` (`fastlanes.rle`) round out Bool coverage: the same FastLanes 1024-row chunked layout the numeric path uses, with a `vortex.bool`-encoded values pool (at most 2 distinct values per chunk) instead of a ptype-width one. `MaskedEncodingEncoder` tries it alongside `vortex.sparse`/`vortex.runend` and keeps the smallest — `DType.Bool` now has every general-purpose encoding family (`vortex.constant`, `vortex.sparse`, `vortex.runend`, `fastlanes.rle`) that `DType.Primitive` has. (internal)
1617

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

TODO.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,6 @@
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-
- [ ] **`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.
2415

2516
## Security
2617

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.github.dfa1.vortex.reader.array;
2+
3+
import io.github.dfa1.vortex.core.model.DType;
4+
5+
/// Lazy FastLanes-RLE-encoded [BoolArray].
6+
///
7+
/// Mirrors [LazyRleLongArray] — see its doc for the chunk/values/indices layout — with `values`
8+
/// a [BoolArray] (at most 2 distinct values per 1024-row chunk) instead of a numeric array.
9+
///
10+
/// @param dtype logical Bool type
11+
/// @param length total logical row count
12+
/// @param values concatenated distinct values per chunk
13+
/// @param indices per-row local index table; length `numChunks * 1024`
14+
/// @param valuesIdxOffsets per-chunk values-pool start offsets; length `numChunks`
15+
/// @param firstOffset absolute origin of the values pool
16+
/// @param valuesLen total values pool length
17+
/// @param numChunks number of FastLanes chunks covered
18+
/// @param offset starting absolute position; logical row `i` maps to
19+
/// absolute `i + offset`
20+
@SuppressWarnings("java:S6218") // internal data carrier; record components are arrays of immutable primitives or refs that flow through pipelines without ever being compared.
21+
public record LazyRleBoolArray(
22+
DType dtype, long length, BoolArray values, int[] indices,
23+
long[] valuesIdxOffsets, long firstOffset, long valuesLen,
24+
int numChunks, int offset)
25+
implements BoolArray {
26+
27+
@Override
28+
public boolean getBoolean(long i) {
29+
int absRow = (int) (i + offset);
30+
int chunkIdx = absRow >>> RleArrays.FL_LOG2;
31+
int rowInChunk = absRow & RleArrays.FL_MASK;
32+
long valueIdxOffset = valuesIdxOffsets[chunkIdx] - firstOffset;
33+
int numChunkValues = RleArrays.chunkValueCount(chunkIdx, numChunks, valuesIdxOffsets, firstOffset, valuesLen);
34+
if (numChunkValues <= 1) {
35+
return numChunkValues == 1 && values.getBoolean(valueIdxOffset);
36+
}
37+
int localIdx = indices[chunkIdx * RleArrays.FL_CHUNK_SIZE + rowInChunk];
38+
if (localIdx >= numChunkValues) {
39+
localIdx = numChunkValues - 1;
40+
}
41+
return values.getBoolean(valueIdxOffset + localIdx);
42+
}
43+
44+
@Override
45+
public void forEachBoolean(BooleanConsumer c) {
46+
RleArrays.walkChunks(length, offset, numChunks,
47+
(chunkIdx, rowInChunk, end) -> processChunk(chunkIdx, rowInChunk, end, c));
48+
}
49+
50+
private void processChunk(int chunkIdx, int rowInChunk, int end, BooleanConsumer c) {
51+
int chunkBase = chunkIdx * RleArrays.FL_CHUNK_SIZE;
52+
long valueIdxOffset = valuesIdxOffsets[chunkIdx] - firstOffset;
53+
int numChunkValues = RleArrays.chunkValueCount(chunkIdx, numChunks, valuesIdxOffsets, firstOffset, valuesLen);
54+
if (numChunkValues <= 1) {
55+
boolean v = numChunkValues == 1 && values.getBoolean(valueIdxOffset);
56+
for (int r = rowInChunk; r < end; r++) {
57+
c.accept(v);
58+
}
59+
} else {
60+
for (int r = rowInChunk; r < end; r++) {
61+
int localIdx = indices[chunkBase + r];
62+
if (localIdx >= numChunkValues) {
63+
localIdx = numChunkValues - 1;
64+
}
65+
c.accept(values.getBoolean(valueIdxOffset + localIdx));
66+
}
67+
}
68+
}
69+
}

reader/src/main/java/io/github/dfa1/vortex/reader/decode/RleEncodingDecoder.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.github.dfa1.vortex.reader.array.LazyConstantIntArray;
1515
import io.github.dfa1.vortex.reader.array.LazyConstantLongArray;
1616
import io.github.dfa1.vortex.reader.array.LazyConstantShortArray;
17+
import io.github.dfa1.vortex.reader.array.LazyRleBoolArray;
1718
import io.github.dfa1.vortex.reader.array.LazyRleByteArray;
1819
import io.github.dfa1.vortex.reader.array.LazyRleDoubleArray;
1920
import io.github.dfa1.vortex.reader.array.LazyRleFloatArray;
@@ -59,12 +60,6 @@ public Array decode(DecodeContext ctx) {
5960
return emptyArray(ctx);
6061
}
6162

62-
if (!(ctx.dtype() instanceof DType.Primitive p)) {
63-
throw new VortexException(EncodingId.FASTLANES_RLE, "expected Primitive dtype, got " + ctx.dtype());
64-
}
65-
PType ptype = p.ptype();
66-
67-
DType valuesDtype = new DType.Primitive(ptype, false);
6863
DType indicesDtype = new DType.Primitive(indicesPtype, false);
6964
DType offsetsDtype = new DType.Primitive(offsetsPtype, false);
7065

@@ -83,6 +78,23 @@ public Array decode(DecodeContext ctx) {
8378
long firstOffset = valuesLen > 0 && valuesIdxOffsets.length > 0 ? valuesIdxOffsets[0] : 0L;
8479
int numChunks = (int) (indicesLen / FL_CHUNK_SIZE);
8580

81+
if (ctx.dtype() instanceof DType.Bool) {
82+
Array valuesArr = ctx.decodeChild(0, DType.BOOL, valuesLen);
83+
Array valuesData = valuesArr instanceof MaskedArray m ? m.inner() : valuesArr;
84+
Array boolResult = new LazyRleBoolArray(ctx.dtype(), rowCount, (BoolArray) valuesData,
85+
indices, valuesIdxOffsets, firstOffset, valuesLen, numChunks, offset);
86+
if (indicesValidity == null) {
87+
return boolResult;
88+
}
89+
return new MaskedArray(boolResult, new OffsetBoolArray(DType.BOOL, rowCount, indicesValidity, offset));
90+
}
91+
92+
if (!(ctx.dtype() instanceof DType.Primitive p)) {
93+
throw new VortexException(EncodingId.FASTLANES_RLE, "expected Primitive dtype, got " + ctx.dtype());
94+
}
95+
PType ptype = p.ptype();
96+
DType valuesDtype = new DType.Primitive(ptype, false);
97+
8698
MemorySegment valuesSeg = ctx.decodeChildSegment(0, valuesDtype, valuesLen);
8799
Array result = switch (ptype) {
88100
case I64, U64 -> new LazyRleLongArray(ctx.dtype(), rowCount,

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,12 @@ private static EncodeResult encodeValues(DType nonNullable, Object values, Encod
8888
/// `vortex.constant` ([ConstantEncodingEncoder]) — a common case that otherwise costs a full
8989
/// `n/8`-byte bitmap for no information. Otherwise, with cascade depth available, also tries
9090
/// `vortex.sparse` ([SparseEncodingEncoder#encodeBool] — wins on a dominant value with
91-
/// scattered rare flips, its patch-index array compressed further) and `vortex.runend`
91+
/// scattered rare flips, its patch-index array compressed further), `vortex.runend`
9292
/// ([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.
93+
/// regardless of which value dominates), and `fastlanes.rle`
94+
/// ([RleEncodingEncoder#encodeBool] — the same clustered-run shape as run-end, FastLanes'
95+
/// vectorized chunked layout instead), keeping whichever candidate is smallest. Without
96+
/// cascade depth, or when none wins, falls back to a raw `vortex.bool` bitmap.
9597
///
9698
/// @param validity per-row validity bitmap
9799
/// @param ctx the encode context
@@ -112,6 +114,10 @@ private static EncodeResult encodeValidity(boolean[] validity, EncodeContext ctx
112114
if (totalBytes(runEnd) < totalBytes(best)) {
113115
best = runEnd;
114116
}
117+
EncodeResult rle = RleEncodingEncoder.encodeBool(validity, ctx);
118+
if (totalBytes(rle) < totalBytes(best)) {
119+
best = rle;
120+
}
115121
return best;
116122
}
117123

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import java.lang.foreign.MemorySegment;
1313
import java.lang.foreign.SegmentAllocator;
14+
import java.util.ArrayList;
1415
import java.util.List;
1516

1617
/// Write-only encoder for `fastlanes.rle`.
@@ -28,6 +29,90 @@ public boolean accepts(DType dtype) {
2829
return dtype instanceof DType.Primitive p && !p.ptype().isFloating();
2930
}
3031

32+
/// Encodes a boolean array as `fastlanes.rle`: the same FastLanes 1024-row chunked
33+
/// layout as the numeric path (see [#encode]), with a `vortex.bool`-encoded values pool
34+
/// (at most 2 distinct values per chunk for a two-valued domain) instead of a ptype-width
35+
/// primitive one. Complements [RunEndEncodingEncoder#encodeBool] (plain run-ends); callers
36+
/// compare against alternatives and keep whichever is smallest.
37+
///
38+
/// @param validity per-row boolean array; must contain at least one `true` and one `false`
39+
/// @param ctx encode context
40+
/// @return the encoded `fastlanes.rle` result
41+
static EncodeResult encodeBool(boolean[] validity, EncodeContext ctx) {
42+
int n = validity.length;
43+
long[] longs = new long[n];
44+
for (int i = 0; i < n; i++) {
45+
longs[i] = validity[i] ? 1L : 0L;
46+
}
47+
48+
int numChunks = (n + FL_CHUNK_SIZE - 1) / FL_CHUNK_SIZE;
49+
int paddedLen = numChunks * FL_CHUNK_SIZE;
50+
51+
long[] globalValues = new long[paddedLen];
52+
short[] globalIndices = new short[paddedLen];
53+
long[] valuesIdxOffsets = new long[numChunks];
54+
55+
long[] chunkInput = new long[FL_CHUNK_SIZE];
56+
long[] chunkValues = new long[FL_CHUNK_SIZE];
57+
short[] chunkIndices = new short[FL_CHUNK_SIZE];
58+
59+
int globalValuesCount = 0;
60+
for (int chunk = 0; chunk < numChunks; chunk++) {
61+
int chunkStart = chunk * FL_CHUNK_SIZE;
62+
int chunkEnd = Math.min(chunkStart + FL_CHUNK_SIZE, n);
63+
int chunkLen = chunkEnd - chunkStart;
64+
65+
System.arraycopy(longs, chunkStart, chunkInput, 0, chunkLen);
66+
long lastVal = longs[chunkEnd - 1];
67+
for (int i = chunkLen; i < FL_CHUNK_SIZE; i++) {
68+
chunkInput[i] = lastVal;
69+
}
70+
71+
int numChunkValues = rleEncode(chunkInput, chunkValues, chunkIndices);
72+
73+
valuesIdxOffsets[chunk] = globalValuesCount;
74+
System.arraycopy(chunkValues, 0, globalValues, globalValuesCount, numChunkValues);
75+
globalValuesCount += numChunkValues;
76+
77+
System.arraycopy(chunkIndices, 0, globalIndices, chunkStart, FL_CHUNK_SIZE);
78+
}
79+
80+
boolean[] valuesArr = new boolean[globalValuesCount];
81+
for (int i = 0; i < globalValuesCount; i++) {
82+
valuesArr[i] = globalValues[i] != 0L;
83+
}
84+
EncodeResult valuesResult = new BoolEncodingEncoder().encode(DType.BOOL, valuesArr, ctx);
85+
MemorySegment indicesSeg = toIndicesSeg(globalIndices, paddedLen, ctx.arena());
86+
MemorySegment offsetsSeg = fromLongsU64(valuesIdxOffsets, numChunks, ctx.arena());
87+
88+
PType indicesPtype = PType.U16;
89+
PType offsetsPtype = PType.U64;
90+
91+
byte[] metaBytes = new ProtoRLEMetadata(
92+
globalValuesCount,
93+
paddedLen,
94+
io.github.dfa1.vortex.core.proto.ProtoPType.fromValue(indicesPtype.ordinal()),
95+
numChunks,
96+
io.github.dfa1.vortex.core.proto.ProtoPType.fromValue(offsetsPtype.ordinal()),
97+
0L
98+
).encode();
99+
100+
int indicesBufIdx = valuesResult.buffers().size();
101+
EncodeNode indicesNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, indicesBufIdx);
102+
EncodeNode offsetsNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, indicesBufIdx + 1);
103+
104+
List<MemorySegment> buffers = new ArrayList<>(valuesResult.buffers());
105+
buffers.add(indicesSeg);
106+
buffers.add(offsetsSeg);
107+
108+
EncodeNode root = new EncodeNode(
109+
EncodingId.FASTLANES_RLE,
110+
MemorySegment.ofArray(metaBytes),
111+
new EncodeNode[]{valuesResult.rootNode(), indicesNode, offsetsNode},
112+
new int[0]);
113+
return new EncodeResult(root, List.copyOf(buffers), null, null);
114+
}
115+
31116
@Override
32117
public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
33118
if (!(dtype instanceof DType.Primitive p)) {

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,58 @@ void roundTrip_negativeValues_i32() {
213213
}
214214
}
215215

216+
@Nested
217+
class Bool {
218+
219+
private static final ReadRegistry BOOL_REGISTRY = TestRegistry.ofDecoders(
220+
DECODER, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder());
221+
222+
@Test
223+
void roundTrip_clusteredRuns() {
224+
// Given — 2000 rows, one contiguous invalid stretch (rows 500-999), spanning multiple
225+
// FastLanes 1024-row chunks.
226+
int n = 2_000;
227+
boolean[] data = new boolean[n];
228+
for (int i = 0; i < n; i++) {
229+
data[i] = i < 500 || i >= 1_000;
230+
}
231+
232+
// When
233+
EncodeResult encoded = RleEncodingEncoder.encodeBool(data, EncodeTestHelper.testCtx());
234+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(encoded, n, DType.BOOL, BOOL_REGISTRY);
235+
Array result = DECODER.decode(ctx);
236+
237+
// Then
238+
assertThat(result.length()).isEqualTo(n);
239+
io.github.dfa1.vortex.reader.array.BoolArray bools = (io.github.dfa1.vortex.reader.array.BoolArray) result;
240+
for (int i = 0; i < n; i++) {
241+
assertThat(bools.getBoolean(i)).as("index %d", i).isEqualTo(data[i]);
242+
}
243+
}
244+
245+
@ParameterizedTest
246+
@ValueSource(ints = {1, 512, 1023, 1024, 1025, 2048, 2049})
247+
void roundTrip_variousLengths(int n) {
248+
// Given — alternating short runs of true/false, various lengths around chunk boundaries
249+
boolean[] data = new boolean[n];
250+
for (int i = 0; i < n; i++) {
251+
data[i] = (i / 7) % 2 == 0;
252+
}
253+
254+
// When
255+
EncodeResult encoded = RleEncodingEncoder.encodeBool(data, EncodeTestHelper.testCtx());
256+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(encoded, n, DType.BOOL, BOOL_REGISTRY);
257+
Array result = DECODER.decode(ctx);
258+
259+
// Then
260+
assertThat(result.length()).isEqualTo(n);
261+
io.github.dfa1.vortex.reader.array.BoolArray bools = (io.github.dfa1.vortex.reader.array.BoolArray) result;
262+
for (int i = 0; i < n; i++) {
263+
assertThat(bools.getBoolean(i)).as("index %d", i).isEqualTo(data[i]);
264+
}
265+
}
266+
}
267+
216268
@Nested
217269
class Decode {
218270

0 commit comments

Comments
 (0)