Skip to content

Commit ecd47ea

Browse files
dfa1claude
andcommitted
perf: constant-encode all-valid/invalid validity
MaskedEncodingEncoder wrote a full n/8-byte raw bitmap for a chunk's validity even when every row shared the same validity. Now detects that case and emits vortex.constant instead, which the reader already decodes generically (no reader change needed). Also documents in TODO.md the larger follow-up gap found while investigating vortex-jni parity: DType.Bool has no RLE/Sparse encoding at all, which is the dominant remaining cost for validity bitmaps with mixed (not all-same) nulls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent effbca0 commit ecd47ea

4 files changed

Lines changed: 102 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
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)
13+
- `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)
1314

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

TODO.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
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.
1523

1624
## Security
1725

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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;
78

89
import java.lang.foreign.MemorySegment;
910
import java.util.ArrayList;
@@ -43,7 +44,7 @@ public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
4344
}
4445
DType nonNullable = dtype.withNullable(false);
4546
EncodeResult valuesResult = encodeValues(nonNullable, values, ctx);
46-
EncodeResult validityResult = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
47+
EncodeResult validityResult = encodeValidity(validity, ctx);
4748

4849
int valuesBufCount = valuesResult.buffers().size();
4950
EncodeNode validityNode = EncodeNode.remapBufferIndices(validityResult.rootNode(), valuesBufCount);
@@ -82,6 +83,35 @@ private static EncodeResult encodeValues(DType nonNullable, Object values, Encod
8283
return pickInner(nonNullable).encode(nonNullable, values, ctx);
8384
}
8485

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.
89+
///
90+
/// @param validity per-row validity bitmap
91+
/// @param ctx the encode context
92+
/// @return the encoded validity child
93+
private static EncodeResult encodeValidity(boolean[] validity, EncodeContext ctx) {
94+
if (isConstantValidity(validity)) {
95+
boolean value = validity.length == 0 || validity[0];
96+
ProtoScalarValue scalar = ProtoScalarValue.ofBoolValue(value);
97+
return EncodeResult.simple(EncodingId.VORTEX_CONSTANT, MemorySegment.ofArray(scalar.encode()));
98+
}
99+
return new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
100+
}
101+
102+
private static boolean isConstantValidity(boolean[] validity) {
103+
if (validity.length == 0) {
104+
return true;
105+
}
106+
boolean first = validity[0];
107+
for (boolean b : validity) {
108+
if (b != first) {
109+
return false;
110+
}
111+
}
112+
return true;
113+
}
114+
85115
/// Returns `values` unchanged, except a `String[]` with null elements is copied with each null
86116
/// replaced by the empty string so cascade encoders (Dict, FSST) can call `getBytes()` safely.
87117
///

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
@@ -12,6 +12,7 @@
1212
import io.github.dfa1.vortex.reader.ReadRegistry;
1313
import io.github.dfa1.vortex.reader.decode.TestRegistry;
1414
import io.github.dfa1.vortex.reader.decode.BoolEncodingDecoder;
15+
import io.github.dfa1.vortex.reader.decode.ConstantEncodingDecoder;
1516
import io.github.dfa1.vortex.reader.decode.MaskedEncodingDecoder;
1617
import io.github.dfa1.vortex.reader.decode.PrimitiveEncodingDecoder;
1718
import org.junit.jupiter.api.Test;
@@ -29,7 +30,10 @@ class MaskedEncodingEncoderTest {
2930
private static final MaskedEncodingDecoder DECODER = new MaskedEncodingDecoder();
3031
private static final PrimitiveEncodingEncoder PRIM_ENCODER = new PrimitiveEncodingEncoder();
3132
private static final BoolEncodingEncoder BOOL_ENCODER = new BoolEncodingEncoder();
33+
private static final MaskedEncodingEncoder SUT = new MaskedEncodingEncoder();
3234
private static final ReadRegistry REGISTRY = TestRegistry.ofDecoders(DECODER, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder());
35+
private static final ReadRegistry REGISTRY_WITH_CONSTANT = TestRegistry.ofDecoders(
36+
DECODER, new PrimitiveEncodingDecoder(), new BoolEncodingDecoder(), new ConstantEncodingDecoder());
3337

3438
private static EncodeResult maskedResult(int[] values, boolean[] validity) {
3539
DType i32 = DType.I32;
@@ -176,4 +180,62 @@ void threeChildrenThrows() {
176180
.isInstanceOf(VortexException.class)
177181
.hasMessageContaining("expected 1 or 2 children");
178182
}
183+
184+
@Test
185+
void allValidColumn_encodesValidityAsConstant() {
186+
// Given
187+
DType i32Nullable = new DType.Primitive(PType.I32, true);
188+
NullableData data = new NullableData(new int[]{1, 2, 3}, new boolean[]{true, true, true});
189+
190+
// When
191+
EncodeResult result = SUT.encode(i32Nullable, data, EncodeTestHelper.testCtx());
192+
193+
// Then — the validity child is a vortex.constant scalar, not a raw bitmap
194+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_CONSTANT);
195+
196+
// And it still round-trips to an all-valid MaskedArray
197+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, 3L, i32Nullable, REGISTRY_WITH_CONSTANT));
198+
MaskedArray masked = (MaskedArray) decoded;
199+
assertThat(masked.isValid(0)).isTrue();
200+
assertThat(masked.isValid(1)).isTrue();
201+
assertThat(masked.isValid(2)).isTrue();
202+
}
203+
204+
@Test
205+
void allInvalidColumn_encodesValidityAsConstant() {
206+
// Given
207+
DType i32Nullable = new DType.Primitive(PType.I32, true);
208+
NullableData data = new NullableData(new int[]{0, 0, 0}, new boolean[]{false, false, false});
209+
210+
// When
211+
EncodeResult result = SUT.encode(i32Nullable, data, EncodeTestHelper.testCtx());
212+
213+
// Then
214+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_CONSTANT);
215+
216+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, 3L, i32Nullable, REGISTRY_WITH_CONSTANT));
217+
MaskedArray masked = (MaskedArray) decoded;
218+
assertThat(masked.isValid(0)).isFalse();
219+
assertThat(masked.isValid(1)).isFalse();
220+
assertThat(masked.isValid(2)).isFalse();
221+
}
222+
223+
@Test
224+
void mixedValidity_stillEncodesAsRawBitmap() {
225+
// Given — a regression guard: mixed validity must not be misdetected as constant
226+
DType i32Nullable = new DType.Primitive(PType.I32, true);
227+
NullableData data = new NullableData(new int[]{1, 2, 3}, new boolean[]{true, false, true});
228+
229+
// When
230+
EncodeResult result = SUT.encode(i32Nullable, data, EncodeTestHelper.testCtx());
231+
232+
// Then
233+
assertThat(result.rootNode().children()[1].encodingId()).isEqualTo(EncodingId.VORTEX_BOOL);
234+
235+
Array decoded = DECODER.decode(DecodeTestHelper.toDecodeContext(result, 3L, i32Nullable, REGISTRY));
236+
MaskedArray masked = (MaskedArray) decoded;
237+
assertThat(masked.isValid(0)).isTrue();
238+
assertThat(masked.isValid(1)).isFalse();
239+
assertThat(masked.isValid(2)).isTrue();
240+
}
179241
}

0 commit comments

Comments
 (0)