Skip to content

Commit c90cbc5

Browse files
dfa1claude
andcommitted
perf(writer): Rust's stats verdicts during cascade estimation (+36% cascading write, -47% alloc)
## perf(writer): apply stats verdicts when measuring cascade children measureBestChild trial-encoded every accepting encoder at every cascade level; only the top-level competition consulted expectedRatio. Rust's choose_best_scheme runs the verdict pass at every level, so a candidate whose verdict is SKIP is now never trial-encoded on a child sample. Two sample-context rules from Rust's compress (compressor/cascade.rs) come with it: an empty child is returned before selection (costs 0), and constant detection does not settle a sample, so ALWAYS_USE is measured like any other candidate. Short-circuiting it made an empty Sparse patch child cost a constant node and flipped taxi's store_and_fwd_flag codes from Sparse (Rust's choice) to bit-packing. Output: NYC taxi 2024-01 (2.96M rows) writes to the same 59,912,206 bytes; two columns differ by <120 bytes (vortex-jni: 44,463,892). JavaVsJniWriteBenchmark.javaWriteCascading, 5 forks: 0.442 +- 0.002 -> 0.439 +- 0.017 ops/s (per fork 0.457 0.449 0.440 0.417 0.434) gc.alloc.rate.norm 7.10 -> 6.87 GB/op (-3%) Small because most Java verdicts still defer to the sample where Rust's schemes skip (RLE, RunEnd, BitPacking, Sequence) or estimate (FoR, Sparse); the next commits port those. ## perf(writer): skip RLE and RunEnd below Rust's run-length thresholds Port Rust's RLEScheme/FloatRLEScheme and RunEndScheme verdicts: skip when the array's runs average fewer than 4 values (RUN_LENGTH_THRESHOLD, RUN_END_THRESHOLD), else defer to the sample. ArrayStats gains averageRunLength (value count / runs, floats compared as values), always computed as in Rust's stats, from one branch-free pass that the capped distinct scan cannot cut short. RunEnd no longer asks for distinct counts, as Rust's scheme does not. Java trial-encoded RLE on every child sample whatever its run length: it was the largest single allocator of a cascading write (~1 GB/op, ~33k trial encodes of worst-case-sized buffers). Output: NYC taxi 2024-01 writes to the same 59,912,206 bytes as before (per column identical); OHLC 10M rows unchanged at 58,012,218 bytes. JavaVsJniWriteBenchmark.javaWriteCascading, 5 forks: 0.439 +- 0.017 -> 0.599 +- 0.010 ops/s (+36%; per fork 0.592 0.616 0.594 0.584 0.612) gc.alloc.rate.norm 6.87 -> 3.64 GB/op (-47%) vortex-jni reference: 0.848 +- 0.005 ops/s Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015R8cf8YM8XFQqrkyhSAtYr
1 parent 5884b85 commit c90cbc5

7 files changed

Lines changed: 194 additions & 48 deletions

File tree

‎CHANGELOG.md‎

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

1010
### Changed
11+
- Cascading writes are about 35% faster and allocate half as much (10M-row OHLC: 0.44 → 0.60 writes/s), to the same bytes: as in Rust, cascade children are no longer trial-encoded with candidates their stats rule out, and RLE/RunEnd skip arrays whose runs average under 4 values ([#465](https://github.com/dfa1/vortex-java/pull/465)).
1112
- **Breaking:** `EncodingEncoder#expectedRatio` takes `(DType, ArrayAndStats)` instead of `(DType, Object, ArrayStats)`, as Rust's `expected_compression_ratio` takes `ArrayAndStats`: the cascade's stats scan now runs on the first `stats()` call and is shared by every candidate ([#464](https://github.com/dfa1/vortex-java/pull/464)).
1213
- **Breaking:** default writes emit Rust's `vortex.zoned` zone map instead of the legacy `vortex.stats`: one zone per 8192 rows regardless of `writeChunk` batches, with Rust's per-dtype stats (64-byte string bounds, `nan_count`) and no zone sum. Calcite and `ZoneReducer` aggregate push-down needs the legacy layout, so on these files it falls back to a scan; target `Editions.CORE_2025_10_0` to keep it. Read new files with this release or later ([#447](https://github.com/dfa1/vortex-java/issues/447)).
1314
- **Breaking:** `WriteOptions.defaults()` cascades up to depth 3, as Rust's writer always does: default writes are smaller (e.g. two-decimal F64 1.06 MB → 924 KB per 1M rows) and slower. `WriteOptions.cascading(0)` restores first-match encoding. A custom encoder list or `WriteRegistry` is now also the cascade's candidate set, with canonical `vortex.primitive` as the fallback ([#458](https://github.com/dfa1/vortex-java/issues/458)).

‎writer/src/main/java/io/github/dfa1/vortex/writer/encode/ArrayStats.java‎

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,21 @@
2323
/// `valueCount / 2 + 1`. [#distinctCount()] is then a lower bound and
2424
/// [#mostFrequentBits()] / [#topFrequency()] are partial — see
2525
/// [#distinctCapped()] for why every consumer can still decide
26+
/// @param averageRunLength `valueCount` divided by the number of runs of equal values, as Rust's
27+
/// `average_run_length` (integer division; floats compare as values, so
28+
/// `-0.0 == 0.0` and every NaN starts a run). Always computed, as in
29+
/// Rust: it is a single branch-free pass, never capped
2630
public record ArrayStats(
2731
long valueCount,
2832
long distinctCount,
2933
long mostFrequentBits,
3034
long topFrequency,
31-
boolean distinctCapped
35+
boolean distinctCapped,
36+
long averageRunLength
3237
) {
3338

3439
/// Sentinel stats for empty arrays.
35-
public static final ArrayStats EMPTY = new ArrayStats(0, 0, 0, 0, false);
40+
public static final ArrayStats EMPTY = new ArrayStats(0, 0, 0, 0, false, 0);
3641

3742
/// Value ranges narrower than this are always counted densely: Rust's
3843
/// `DENSE_DISTINCT_ALWAYS_RANGE` (covers every 8-bit array).
@@ -54,12 +59,10 @@ public static ArrayStats compute(PType ptype, Object data, StatsOptions options)
5459
if (n == 0) {
5560
return EMPTY;
5661
}
57-
if (options == StatsOptions.NONE) {
58-
return new ArrayStats(n, -1, 0, 0, false);
59-
}
62+
long averageRunLength = n / runs(ptype, data, n);
6063
if (!options.countDistinct() && !options.trackMostFrequent()) {
6164
// Nothing to accumulate — the scan below would read every element and discard it.
62-
return new ArrayStats(n, -1, 0, 0, false);
65+
return new ArrayStats(n, -1, 0, 0, false, averageRunLength);
6366
}
6467

6568
// Stop once the distinct count passes half the rows: past that point every consumer's
@@ -77,7 +80,7 @@ public static ArrayStats compute(PType ptype, Object data, StatsOptions options)
7780
if (!ptype.isFloating()) {
7881
// Widened once (zero-copy for I64/U64) so one loop serves every integer width.
7982
long[] values = PrimitiveArrays.toLongs(data, ptype, EncodingId.VORTEX_PRIMITIVE);
80-
return integerStats(values, n, cap, options.countDistinct());
83+
return integerStats(values, n, cap, options.countDistinct(), averageRunLength);
8184
}
8285

8386
// Sized for the low-cardinality case and grown from there, NOT for the cap. Pre-sizing
@@ -91,7 +94,7 @@ public static ArrayStats compute(PType ptype, Object data, StatsOptions options)
9194
case F32 -> scanF32((float[]) data, n, counts, cap);
9295
default -> scanF64((double[]) data, n, counts, cap);
9396
};
94-
return fromCounts(n, counts, capped, options.countDistinct());
97+
return fromCounts(n, counts, capped, options.countDistinct(), averageRunLength);
9598
}
9699

97100
/// Integer stats as Rust's `typed_int_stats` (vortex-compressor #10037, #10041) computes
@@ -107,8 +110,9 @@ public static ArrayStats compute(PType ptype, Object data, StatsOptions options)
107110
/// @param n element count, at least 1
108111
/// @param cap distinct count past which the hashed scan may stop
109112
/// @param countDistinct whether [#distinctCount()] is reported
113+
/// @param averageRunLength the already computed [#averageRunLength()]
110114
/// @return the stats
111-
private static ArrayStats integerStats(long[] a, int n, long cap, boolean countDistinct) {
115+
private static ArrayStats integerStats(long[] a, int n, long cap, boolean countDistinct, long averageRunLength) {
112116
long min = a[0];
113117
long max = a[0];
114118
for (int i = 0; i < n; i++) {
@@ -122,7 +126,7 @@ private static ArrayStats integerStats(long[] a, int n, long cap, boolean countD
122126
long span = max - min;
123127
if (span >= 0 && (span < DENSE_ALWAYS_RANGE
124128
|| span < DENSE_MAX_RANGE && span < n)) {
125-
return denseStats(a, n, min, (int) span + 1, countDistinct);
129+
return denseStats(a, n, min, (int) span + 1, countDistinct, averageRunLength);
126130
}
127131

128132
LongIntMap counts = new LongIntMap(Math.min(n, 2048));
@@ -133,19 +137,20 @@ private static ArrayStats integerStats(long[] a, int n, long cap, boolean countD
133137
if (v != prev) {
134138
counts.increment(prev, pending);
135139
if (counts.size() > cap) {
136-
return fromCounts(n, counts, true, countDistinct);
140+
return fromCounts(n, counts, true, countDistinct, averageRunLength);
137141
}
138142
prev = v;
139143
pending = 0;
140144
}
141145
pending++;
142146
}
143147
counts.increment(prev, pending);
144-
return fromCounts(n, counts, counts.size() > cap, countDistinct);
148+
return fromCounts(n, counts, counts.size() > cap, countDistinct, averageRunLength);
145149
}
146150

147151
/// Counts by `value - min` into an array: no hashing, and never capped.
148-
private static ArrayStats denseStats(long[] a, int n, long min, int range, boolean countDistinct) {
152+
private static ArrayStats denseStats(long[] a, int n, long min, int range, boolean countDistinct,
153+
long averageRunLength) {
149154
int[] counts = new int[range];
150155
for (int i = 0; i < n; i++) {
151156
counts[(int) (a[i] - min)]++;
@@ -163,15 +168,62 @@ private static ArrayStats denseStats(long[] a, int n, long min, int range, boole
163168
topIndex = i;
164169
}
165170
}
166-
return new ArrayStats(n, countDistinct ? distinct : -1L, min + topIndex, counts[topIndex], false);
171+
return new ArrayStats(n, countDistinct ? distinct : -1L, min + topIndex, counts[topIndex], false,
172+
averageRunLength);
167173
}
168174

169-
private static ArrayStats fromCounts(int n, LongIntMap counts, boolean capped, boolean countDistinct) {
175+
private static ArrayStats fromCounts(int n, LongIntMap counts, boolean capped, boolean countDistinct,
176+
long averageRunLength) {
170177
LongIntMap.Entry top = counts.maxEntry();
171178
long topFreqBits = top == null ? 0L : top.key();
172179
int topFreq = top == null ? 0 : top.value();
173180
long distinct = countDistinct ? counts.size() : -1L;
174-
return new ArrayStats(n, distinct, topFreqBits, topFreq, capped);
181+
return new ArrayStats(n, distinct, topFreqBits, topFreq, capped, averageRunLength);
182+
}
183+
184+
/// Runs of equal values: 1 plus every change between neighbors. One loop per carrier with a
185+
/// branch-free body, so C2 vectorizes it (CLAUDE.md hot-loop rule).
186+
private static long runs(PType ptype, Object data, int n) {
187+
long changes = 0;
188+
switch (ptype) {
189+
case I8, U8 -> {
190+
byte[] a = (byte[]) data;
191+
for (int i = 1; i < n; i++) {
192+
changes += a[i] != a[i - 1] ? 1 : 0;
193+
}
194+
}
195+
case I16, U16, F16 -> {
196+
short[] a = (short[]) data;
197+
for (int i = 1; i < n; i++) {
198+
changes += a[i] != a[i - 1] ? 1 : 0;
199+
}
200+
}
201+
case I32, U32 -> {
202+
int[] a = (int[]) data;
203+
for (int i = 1; i < n; i++) {
204+
changes += a[i] != a[i - 1] ? 1 : 0;
205+
}
206+
}
207+
case I64, U64 -> {
208+
long[] a = (long[]) data;
209+
for (int i = 1; i < n; i++) {
210+
changes += a[i] != a[i - 1] ? 1 : 0;
211+
}
212+
}
213+
case F32 -> {
214+
float[] a = (float[]) data;
215+
for (int i = 1; i < n; i++) {
216+
changes += a[i] != a[i - 1] ? 1 : 0;
217+
}
218+
}
219+
case F64 -> {
220+
double[] a = (double[]) data;
221+
for (int i = 1; i < n; i++) {
222+
changes += a[i] != a[i - 1] ? 1 : 0;
223+
}
224+
}
225+
}
226+
return changes + 1;
175227
}
176228

177229
private static boolean scanF16(short[] a, int n, LongIntMap counts, long cap) {

‎writer/src/main/java/io/github/dfa1/vortex/writer/encode/CascadingCompressor.java‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -213,23 +213,31 @@ private EncodeResult encodeWithCtx(DType dtype, Object data, EncodeContext ctx)
213213
// excluded set so spliceResult's notApplicable retry can rotate to the next accepting
214214
// encoding (e.g. DateTimePartsEncoding → ExtEncoding when the input is raw storage rather
215215
// than DateTimePartsData).
216-
if (!(dtype instanceof DType.Primitive p)) {
216+
if (!(dtype instanceof DType.Primitive)) {
217217
return spliceResult(findPrimitiveEncoding(dtype, ctx.excluded()), dtype, data, ctx);
218218
}
219219

220-
// Stats-first selection (Rust vortex-compressor pattern): merge every eligible
221-
// encoder's StatsOptions so the one scan, run lazily by the first encoder whose
222-
// expectedRatio() reads stats, satisfies every later one. ALWAYS_USE short-circuits;
223-
// SKIP excludes; COMPLETE defers to the sample-encoded path below.
220+
return competeAndEncode(dtype, withStats(dtype, data, ctx), ctx,
221+
sampleSize -> primitiveBytes(dtype, sampleSize));
222+
}
223+
224+
/// Bundles `data` with stats under the [StatsOptions] merged from every eligible encoder,
225+
/// so the one scan, run lazily by the first encoder whose expectedRatio() reads stats,
226+
/// satisfies every later one (Rust vortex-compressor pattern).
227+
private ArrayAndStats withStats(DType dtype, Object data, EncodeContext ctx) {
224228
StatsOptions merged = StatsOptions.NONE;
225229
for (EncodingEncoder enc : encodings) {
226230
if (enc.accepts(dtype) && !ctx.excluded().contains(enc.encodingId())) {
227231
merged = StatsOptions.merge(merged, enc.statsOptions());
228232
}
229233
}
234+
return new ArrayAndStats(dtype, data, merged);
235+
}
230236

231-
return competeAndEncode(dtype, new ArrayAndStats(p, data, merged), ctx,
232-
sampleSize -> primitiveBytes(dtype, sampleSize));
237+
/// Whether `dtype` goes through the competition ([#competeAndEncode]) rather than a
238+
/// first-match dispatch — and so whether stats verdicts apply to it.
239+
private static boolean competes(DType dtype) {
240+
return dtype instanceof DType.Primitive || dtype instanceof DType.Utf8 || dtype instanceof DType.Binary;
233241
}
234242

235243
/// Shared sample-and-measure competition: stats-based skip/always-use sweep, then a
@@ -317,13 +325,33 @@ private long measureStep(EncodingEncoder enc, CascadeStep step, EncodeContext ct
317325
return total;
318326
}
319327

328+
/// Smallest size any candidate encodes the (already sampled) child `data` to.
329+
///
330+
/// Selects as Rust's `choose_best_scheme` does at every cascade level, not only at the top:
331+
/// stats verdicts first, so a candidate whose verdict is SKIP is never trial-encoded. Without
332+
/// it every accepting encoder was trial-encoded at every level, which was most of a cascading
333+
/// write's CPU and allocation.
334+
///
335+
/// Two sample-context rules from Rust's `compress` (`compressor/cascade.rs`): an empty array
336+
/// is returned before any selection, so it costs nothing; and constant detection is skipped,
337+
/// since a constant sample does not imply a constant array — so ALWAYS_USE does not settle the
338+
/// child here, the candidate is only measured like the rest. Short-circuiting it made an
339+
/// empty patch child of Sparse cost a constant node and flipped taxi's dict codes from Sparse
340+
/// (Rust's choice) to bit-packing.
320341
private long measureBestChild(DType dtype, Object data, EncodeContext ctx) {
321342
int n = dataLength(data);
322343
long best = primitiveBytes(dtype, n);
344+
if (n == 0) {
345+
return best;
346+
}
347+
ArrayAndStats input = competes(dtype) ? withStats(dtype, data, ctx) : null;
323348
for (EncodingEncoder enc : encodings) {
324349
if (!enc.accepts(dtype) || ctx.excluded().contains(enc.encodingId())) {
325350
continue;
326351
}
352+
if (input != null && enc.expectedRatio(dtype, input) == Estimate.SKIP) {
353+
continue;
354+
}
327355
CascadeStep step = enc.encodeCascade(dtype, data, ctx);
328356
if (!step.isTerminal() && ctx.allowedCascading() == 0) {
329357
continue;

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,25 @@ public final class RleEncodingEncoder implements EncodingEncoder {
2121

2222
private static final int FL_CHUNK_SIZE = 1024;
2323

24+
/// Rust's `RUN_LENGTH_THRESHOLD` (`vortex-btrblocks` `schemes/integer/mod.rs`), shared by its
25+
/// integer and float RLE schemes.
26+
private static final long RUN_LENGTH_THRESHOLD = 4;
27+
2428
@Override
2529
public EncodingId encodingId() {
2630
return EncodingId.FASTLANES_RLE;
2731
}
2832

33+
/// Rust's `RLEScheme`/`FloatRLEScheme` verdict: skip unless runs average at least
34+
/// [#RUN_LENGTH_THRESHOLD] values, else defer to the sample.
35+
@Override
36+
public Estimate expectedRatio(DType dtype, ArrayAndStats data) {
37+
if (!(dtype instanceof DType.Primitive)) {
38+
return Estimate.COMPLETE;
39+
}
40+
return data.stats().averageRunLength() < RUN_LENGTH_THRESHOLD ? Estimate.SKIP : Estimate.COMPLETE;
41+
}
42+
2943
@Override
3044
public boolean accepts(DType dtype) {
3145
// every primitive, floats included (Rust's FloatRLEScheme): runs compare raw bits, so -0.0,

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

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
/// Write-only encoder for `vortex.runend`.
2020
public final class RunEndEncodingEncoder implements EncodingEncoder {
2121

22+
/// Rust's `RUN_END_THRESHOLD` (`vortex-btrblocks` `schemes/integer/runend.rs`).
23+
private static final long RUN_END_THRESHOLD = 4;
24+
2225
@Override
2326
public EncodingId encodingId() {
2427
return EncodingId.VORTEX_RUNEND;
@@ -29,35 +32,15 @@ public boolean accepts(DType dtype) {
2932
return dtype instanceof DType.Primitive p && !p.ptype().isFloating();
3033
}
3134

32-
@Override
33-
public StatsOptions statsOptions() {
34-
return new StatsOptions(true, true);
35-
}
36-
35+
/// Rust's `RunEndScheme` verdict: skip unless runs average at least [#RUN_END_THRESHOLD]
36+
/// values, else defer to the sample. Needs no distinct count, so it declares no
37+
/// [StatsOptions], as Rust's scheme declares none.
3738
@Override
3839
public Estimate expectedRatio(DType dtype, ArrayAndStats data) {
39-
ArrayStats stats = data.stats();
40-
if (!(dtype instanceof DType.Primitive) || !stats.hasDistinctCount()) {
40+
if (!(dtype instanceof DType.Primitive)) {
4141
return Estimate.COMPLETE;
4242
}
43-
long n = stats.valueCount();
44-
long distinct = stats.distinctCount();
45-
if (n == 0) {
46-
return Estimate.SKIP;
47-
}
48-
// The only consumer the capped scan does not settle: `distinct >= n` needs the exact
49-
// count, and a capped scan only proves distinct > n/2 + 1. Defer to the sample instead
50-
// of guessing — a 1024-row sample encode is far cheaper than the probes the cap saved.
51-
if (stats.distinctCapped()) {
52-
return Estimate.COMPLETE;
53-
}
54-
// Skip rule: if every value is distinct, each row is its own run — pure overhead.
55-
// Defer to the sample-encoded path otherwise; RunEnd's actual compression depends
56-
// on run-length distribution which is not summarized by distinct count alone.
57-
if (distinct >= n) {
58-
return Estimate.SKIP;
59-
}
60-
return Estimate.COMPLETE;
43+
return data.stats().averageRunLength() < RUN_END_THRESHOLD ? Estimate.SKIP : Estimate.COMPLETE;
6144
}
6245

6346
/// Encodes a boolean array as `vortex.runend`: consecutive equal values collapse into one run.

‎writer/src/test/java/io/github/dfa1/vortex/writer/encode/ArrayStatsTest.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,34 @@ void compute_everyIntegerCountingPath_matchesPerRowReference(PType ptype, Object
217217
assertThat(result.mostFrequentBits()).isEqualTo(expectedTopBits);
218218
}
219219

220+
@Test
221+
void compute_averageRunLength_isValueCountOverRuns() {
222+
// Given: a date-like column, each value repeated 30 times, 100 runs
223+
int[] data = new int[3_000];
224+
for (int i = 0; i < data.length; i++) {
225+
data[i] = i / 30;
226+
}
227+
228+
// When: run length is reported even when no distinct stats are requested, as in Rust
229+
ArrayStats result = ArrayStats.compute(PType.I32, data, StatsOptions.NONE);
230+
231+
// Then
232+
assertThat(result.averageRunLength()).isEqualTo(30);
233+
}
234+
235+
@Test
236+
void compute_averageRunLength_comparesFloatsAsValues() {
237+
// Given: Rust's float stats compare with `!=`, so -0.0 continues a run of 0.0 and every
238+
// NaN starts a new one: runs are [0.0, -0.0, 0.0] [NaN] [NaN] [1.0] -> 4 runs over 6 values
239+
double[] data = {0.0, -0.0, 0.0, Double.NaN, Double.NaN, 1.0};
240+
241+
// When
242+
ArrayStats result = ArrayStats.compute(PType.F64, data, StatsOptions.NONE);
243+
244+
// Then: integer division, as Rust's value_count / runs
245+
assertThat(result.averageRunLength()).isEqualTo(1);
246+
}
247+
220248
private static long[] widen(byte[] a, boolean unsigned) {
221249
long[] out = new long[a.length];
222250
for (int i = 0; i < a.length; i++) {

0 commit comments

Comments
 (0)