Skip to content

Commit dfe1bc4

Browse files
dfa1claude
andcommitted
perf(compute): add ComputeKernelBenchmark baseline for encoded columns
Measures Compute.filter / Compute.sum over genuinely encoded columns (ALP-encoded doubles → LazyAlpDoubleArray, FoR longs → LazyForLongArray, low-cardinality → DictLongArray, plus a plain Materialized control), the control for the upcoming encoded-domain kernel specialisation (ADR 0013). @setup asserts the decoded Array types so the baseline can never silently measure a plain column. Today the kernels decode element-by-element via the typed accessor. Baseline (1M rows, Apple M5, us/op): filterAlpDouble 4426, filterDict 3403, filterForLong 3071, filterPlainControl 2952, sumAlpDouble 586, filterThenSumAlp 7405 — encoded filter runs 1.04-1.5x the plain control, ALP carrying the most headroom for an integer-domain compare. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dde88ac commit dfe1bc4

1 file changed

Lines changed: 258 additions & 0 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package io.github.dfa1.vortex.performance;
2+
3+
import io.github.dfa1.vortex.core.model.DType;
4+
import io.github.dfa1.vortex.reader.Chunk;
5+
import io.github.dfa1.vortex.reader.ReadRegistry;
6+
import io.github.dfa1.vortex.reader.VortexReader;
7+
import io.github.dfa1.vortex.reader.array.Array;
8+
import io.github.dfa1.vortex.reader.array.DictLongArray;
9+
import io.github.dfa1.vortex.reader.array.LazyAlpDoubleArray;
10+
import io.github.dfa1.vortex.reader.array.LazyForLongArray;
11+
import io.github.dfa1.vortex.reader.array.MaterializedLongArray;
12+
import io.github.dfa1.vortex.reader.compute.Compute;
13+
import io.github.dfa1.vortex.reader.compute.Mask;
14+
import io.github.dfa1.vortex.reader.compute.Predicate;
15+
import io.github.dfa1.vortex.writer.VortexWriter;
16+
import io.github.dfa1.vortex.writer.WriteOptions;
17+
import java.io.IOException;
18+
import java.lang.foreign.Arena;
19+
import java.nio.channels.FileChannel;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.nio.file.StandardOpenOption;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Random;
26+
import java.util.concurrent.TimeUnit;
27+
import org.openjdk.jmh.annotations.Benchmark;
28+
import org.openjdk.jmh.annotations.BenchmarkMode;
29+
import org.openjdk.jmh.annotations.Fork;
30+
import org.openjdk.jmh.annotations.Level;
31+
import org.openjdk.jmh.annotations.Measurement;
32+
import org.openjdk.jmh.annotations.Mode;
33+
import org.openjdk.jmh.annotations.OutputTimeUnit;
34+
import org.openjdk.jmh.annotations.Scope;
35+
import org.openjdk.jmh.annotations.Setup;
36+
import org.openjdk.jmh.annotations.State;
37+
import org.openjdk.jmh.annotations.TearDown;
38+
import org.openjdk.jmh.annotations.Warmup;
39+
40+
/// Baseline for the encoded-domain compute-kernel specialisation of ADR 0013.
41+
///
42+
/// The compute kernels ([Compute#filter(Array, Predicate, Arena)] and
43+
/// [Compute#sum(Array, Mask)]) today decode every element through the typed accessor: the
44+
/// generic streaming filter path and the type-specialised, boxing-free reduce lane both read
45+
/// `getLong(i)` / `getDouble(i)` per row, so an ALP or Frame-of-Reference column is fully
46+
/// reconstructed into the value domain before a single comparison or addition runs. The future
47+
/// work compares and reduces directly in the encoded integer domain (ALP residuals, FoR offsets)
48+
/// without decoding. This benchmark pins the CURRENT decode-via-accessor cost so that win is
49+
/// provable: the same `@Benchmark` methods will show the speedup once the specialised kernels land.
50+
///
51+
/// One million rows are written into a single chunk with `WriteOptions.cascading(3)`, so the
52+
/// writer picks real encodings and the four columns decode to:
53+
/// - `price` — `f64` rounded to two decimals, chosen for ALP, decodes to [LazyAlpDoubleArray].
54+
/// - `measure` — `i64` with a large base and a bounded spread, chosen for Frame-of-Reference,
55+
/// decodes to [LazyForLongArray].
56+
/// - `category` — `i64` with sixteen distinct values, chosen for dictionary encoding, decodes to
57+
/// [DictLongArray].
58+
/// - `plain` — `i64` full-range random, with no encoding savings, decodes to
59+
/// [MaterializedLongArray] as an apples baseline for the non-encoded cost.
60+
///
61+
/// `@Setup` asserts each decoded column is the expected encoded type and fails loudly otherwise,
62+
/// so the baseline can never silently measure a plain column.
63+
///
64+
/// Run: java -jar performance/target/benchmarks.jar ComputeKernelBenchmark
65+
@State(Scope.Benchmark)
66+
@BenchmarkMode(Mode.AverageTime)
67+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
68+
@Warmup(iterations = 3, time = 2)
69+
@Measurement(iterations = 5, time = 2)
70+
@Fork(value = 1, jvmArgsAppend = {
71+
"--add-opens", "java.base/java.nio=ALL-UNNAMED",
72+
"--enable-native-access=ALL-UNNAMED",
73+
"--sun-misc-unsafe-memory-access=allow"
74+
})
75+
public class ComputeKernelBenchmark {
76+
77+
private static final int ROWS = 1_000_000;
78+
private static final long SEED = 42L;
79+
80+
/// Large base so the Frame-of-Reference reference value is non-zero and FoR is chosen.
81+
private static final long MEASURE_BASE = 1_700_000_000_000L;
82+
/// Bounded spread keeps the FoR residuals narrow (≈ 17 bits) without collapsing cardinality.
83+
private static final int MEASURE_SPREAD = 100_000;
84+
/// Low cardinality drives the dictionary encoding on the `category` column.
85+
private static final int CATEGORY_CARDINALITY = 16;
86+
87+
private static final List<String> COLUMNS =
88+
List.of("price", "measure", "category", "plain");
89+
90+
private static final DType.Struct SCHEMA = new DType.Struct(
91+
COLUMNS,
92+
List.of(DType.F64, DType.I64, DType.I64, DType.I64),
93+
false);
94+
95+
/// Selects ≈ half of the uniform `[0, 1000)` `price` column.
96+
private static final double PRICE_THRESHOLD = 500.0;
97+
/// Selects ≈ half of the `measure` column (base + uniform `[0, MEASURE_SPREAD)`).
98+
private static final long MEASURE_THRESHOLD = MEASURE_BASE + MEASURE_SPREAD / 2;
99+
/// Selects one of sixteen categories — ≈ 1/16 selectivity.
100+
private static final long CATEGORY_VALUE = 7L;
101+
102+
private Path file;
103+
private ReadRegistry registry;
104+
private VortexReader reader;
105+
private Chunk chunk;
106+
107+
private LazyAlpDoubleArray price;
108+
private LazyForLongArray measure;
109+
private DictLongArray category;
110+
private MaterializedLongArray plain;
111+
private long rows;
112+
113+
@Setup(Level.Trial)
114+
public void setup() throws IOException {
115+
registry = ReadRegistry.loadAll();
116+
file = Files.createTempFile("compute-kernel-bench", ".vtx");
117+
write(file);
118+
119+
reader = VortexReader.open(file, registry);
120+
if (reader.chunkCount() != 1) {
121+
throw new IllegalStateException("expected a single chunk, got " + reader.chunkCount());
122+
}
123+
chunk = reader.decodeChunk(0, COLUMNS);
124+
rows = chunk.rowCount();
125+
126+
Array priceArr = chunk.column("price");
127+
Array measureArr = chunk.column("measure");
128+
Array categoryArr = chunk.column("category");
129+
Array plainArr = chunk.column("plain");
130+
131+
System.out.printf("[ComputeKernelBenchmark] decoded column types:%n");
132+
System.out.printf(" price -> %s%n", priceArr.getClass().getName());
133+
System.out.printf(" measure -> %s%n", measureArr.getClass().getName());
134+
System.out.printf(" category -> %s%n", categoryArr.getClass().getName());
135+
System.out.printf(" plain -> %s%n", plainArr.getClass().getName());
136+
137+
price = requireType(priceArr, LazyAlpDoubleArray.class, "price");
138+
measure = requireType(measureArr, LazyForLongArray.class, "measure");
139+
category = requireType(categoryArr, DictLongArray.class, "category");
140+
plain = requireType(plainArr, MaterializedLongArray.class, "plain");
141+
}
142+
143+
@TearDown(Level.Trial)
144+
public void tearDown() throws IOException {
145+
if (chunk != null) {
146+
chunk.close();
147+
}
148+
if (reader != null) {
149+
reader.close();
150+
}
151+
if (file != null) {
152+
Files.deleteIfExists(file);
153+
}
154+
}
155+
156+
/// Filters the ALP-encoded `price` column with `price > 500`, decoding every double through the
157+
/// accessor before the compare. Returns the selected count so the mask cannot be eliminated.
158+
///
159+
/// @return the number of selected rows
160+
@Benchmark
161+
public long filterAlpDouble() {
162+
try (Arena arena = Arena.ofConfined()) {
163+
Mask result = Compute.filter(price, new Predicate.Gt(PRICE_THRESHOLD), arena);
164+
return result.trueCount();
165+
}
166+
}
167+
168+
/// Filters the Frame-of-Reference-encoded `measure` column with `measure > base + spread/2`,
169+
/// reconstructing each `offset + ref` long through the accessor before the compare.
170+
///
171+
/// @return the number of selected rows
172+
@Benchmark
173+
public long filterForLong() {
174+
try (Arena arena = Arena.ofConfined()) {
175+
Mask result = Compute.filter(measure, new Predicate.Gt(MEASURE_THRESHOLD), arena);
176+
return result.trueCount();
177+
}
178+
}
179+
180+
/// Filters the dictionary-encoded `category` column with `category == 7`, resolving each code
181+
/// through the dictionary before the compare.
182+
///
183+
/// @return the number of selected rows
184+
@Benchmark
185+
public long filterDict() {
186+
try (Arena arena = Arena.ofConfined()) {
187+
Mask result = Compute.filter(category, new Predicate.Eq(CATEGORY_VALUE), arena);
188+
return result.trueCount();
189+
}
190+
}
191+
192+
/// Control: filters the plain (non-encoded) `plain` column with `plain > 0`, reading each long
193+
/// straight from the materialised segment. Shows the cost without an encoding to unwind.
194+
///
195+
/// @return the number of selected rows
196+
@Benchmark
197+
public long filterPlainControl() {
198+
try (Arena arena = Arena.ofConfined()) {
199+
Mask result = Compute.filter(plain, new Predicate.Gt(0L), arena);
200+
return result.trueCount();
201+
}
202+
}
203+
204+
/// Reduces the ALP-encoded `price` column over an all-selected mask, the boxing-free reduce
205+
/// lane decoding every double through the accessor before the addition.
206+
///
207+
/// @return the sum of all `price` values
208+
@Benchmark
209+
public Number sumAlpDouble() {
210+
return Compute.sum(price, Mask.allTrue(rows));
211+
}
212+
213+
/// Realistic pipeline: filter the ALP-encoded `price` column, then sum the FoR-encoded `measure`
214+
/// column over the resulting mask. Both stages decode through the accessor today.
215+
///
216+
/// @return the sum of `measure` over the rows where `price > 500`
217+
@Benchmark
218+
public Number filterThenSumAlp() {
219+
try (Arena arena = Arena.ofConfined()) {
220+
Mask mask = Compute.filter(price, new Predicate.Gt(PRICE_THRESHOLD), arena);
221+
return Compute.sum(measure, mask);
222+
}
223+
}
224+
225+
private void write(Path path) throws IOException {
226+
double[] priceData = new double[ROWS];
227+
long[] measureData = new long[ROWS];
228+
long[] categoryData = new long[ROWS];
229+
long[] plainData = new long[ROWS];
230+
231+
var rng = new Random(SEED);
232+
for (int i = 0; i < ROWS; i++) {
233+
priceData[i] = Math.round(rng.nextDouble() * 1000.0 * 100.0) / 100.0;
234+
measureData[i] = MEASURE_BASE + rng.nextInt(MEASURE_SPREAD);
235+
categoryData[i] = rng.nextInt(CATEGORY_CARDINALITY);
236+
plainData[i] = rng.nextLong();
237+
}
238+
239+
try (FileChannel ch = FileChannel.open(path,
240+
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
241+
VortexWriter writer = VortexWriter.create(ch, SCHEMA, WriteOptions.cascading(3))) {
242+
writer.writeChunk(Map.of(
243+
"price", priceData,
244+
"measure", measureData,
245+
"category", categoryData,
246+
"plain", plainData));
247+
}
248+
}
249+
250+
private static <T extends Array> T requireType(Array array, Class<T> expected, String column) {
251+
if (!expected.isInstance(array)) {
252+
throw new IllegalStateException("column '" + column + "' decoded to "
253+
+ array.getClass().getName() + ", expected " + expected.getName()
254+
+ " — the encoded baseline would be measuring the wrong path");
255+
}
256+
return expected.cast(array);
257+
}
258+
}

0 commit comments

Comments
 (0)