Skip to content

Commit 83ddb3f

Browse files
dfa1claude
andcommitted
bench(compute): pin the ADR 0019 baseline workloads
Two benchmarks pin the facade's levers the way the current API forces them to run: fusedFilteredAggregateTwoAggregates (one dict leaf, two aggregate columns = two kernel calls re-scanning the filter, 137.3 ms/op) and fusedFilteredAggregateMulti (the ADR example: two-leaf AND declining the dict lane onto the per-row RowPredicate path, times two calls — 2269.1 ms/op, ~60x the single-leaf dict lane). Numbers recorded in ADR 0019 as the baseline the implementation must beat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4a2187a commit 83ddb3f

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

docs/adr/0019-columnar-transducer-facade.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ The Calcite `VortexAggregatePushDownRule` boundary tier is the first consumer: i
8484
aggregate columns one `filteredAggregate` call per column; a multi-aggregate pipeline folds them in
8585
one scan of the filter column.
8686

87+
### Baseline (measured 2026-07-03, `ComputeKernelBenchmark`, 100M rows)
88+
89+
The two levers, pinned as benchmarks the implementation must beat — expressed the only way the
90+
current API allows:
91+
92+
| Workload | Today | ms/op |
93+
| --- | --- | --- |
94+
| 1 dict leaf × 2 aggregates (`fusedFilteredAggregateTwoAggregates`) | 2 kernel calls, filter re-scanned per aggregate | 137.3 ± 0.1 |
95+
| 2-leaf `AND` × 2 aggregates (`fusedFilteredAggregateMulti`, this ADR's example) | multi-leaf declines the dict lane → per-row `RowPredicate` path, × 2 calls | 2269.1 ± 19.8 |
96+
97+
For scale: the single-leaf single-aggregate dict lane runs the same scan in 36.2 ms and the raw
98+
code scan in ~25 ms. The pipeline's compile step attacks both levers at once — the dict leaf drives
99+
the scan, the residual leaf is tested only on code matches, and every aggregate folds from that one
100+
pass — so the example workload's target is tens of milliseconds, not seconds.
101+
87102
## Consequences
88103

89104
### Positive

performance/src/main/java/io/github/dfa1/vortex/performance/ComputeKernelBenchmark.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,55 @@ public long fusedFilteredAggregateDict() {
274274
return acc;
275275
}
276276

277+
/// ADR 0019 baseline, lever 1 — multi-aggregate over one filter: `SUM`/`MIN`(measure) and
278+
/// `SUM`(plain) under the same single-leaf `category == 7` filter, expressed the only way the
279+
/// current API allows — one [Compute#filteredAggregate(Chunk, RowFilter, String)] call PER
280+
/// aggregate column, so the filter column is re-scanned once per aggregate. The transducer
281+
/// façade folds every aggregate from one scan; this method is the number it must beat.
282+
///
283+
/// Result (2026-07-03, 100M rows): 137.3 ± 0.1 ms/op — ≈ 2× the single-aggregate lane, the
284+
/// redundant re-scan plus the second aggregate's (random-access `plain`) match reads.
285+
///
286+
/// @return the two folds' selected counts and sums combined, so JMH cannot eliminate either call
287+
@Benchmark
288+
public long fusedFilteredAggregateTwoAggregates() {
289+
long acc = 0;
290+
for (Chunk chunk : chunks) {
291+
RowFilter filter = RowFilter.eq("category", CATEGORY_VALUE);
292+
FilteredAggregate measure = Compute.filteredAggregate(chunk, filter, "measure");
293+
FilteredAggregate plain = Compute.filteredAggregate(chunk, filter, "plain");
294+
acc += measure.selectedRows() + measure.sum().longValue()
295+
+ ((Long) measure.min()).longValue() + plain.sum().longValue();
296+
}
297+
return acc;
298+
}
299+
300+
/// ADR 0019 baseline, lever 2 — the full façade example: a two-leaf `AND`
301+
/// (`category == 7 AND price > 500`, ≈ 1/32 selectivity) with two aggregate columns. A
302+
/// multi-leaf filter declines the dict code-scan lane, so every row pays the per-leaf
303+
/// [Compute] `RowPredicate` accessor path (dict chunk-lookup for `category`, lazy ALP decode
304+
/// for `price`), twice — once per aggregate call. The façade's compile step drives the scan
305+
/// with the dict leaf, tests the residual `price` leaf only on code matches, and feeds both
306+
/// aggregates from that single pass.
307+
///
308+
/// Result (2026-07-03, 100M rows): 2269.1 ± 19.8 ms/op — the multi-leaf decline costs ≈ 60×
309+
/// the single-leaf dict lane's 36.2 ms; ADR 0019's target for this exact workload is tens of
310+
/// milliseconds.
311+
///
312+
/// @return the two folds' selected counts and sums combined, so JMH cannot eliminate either call
313+
@Benchmark
314+
public long fusedFilteredAggregateMulti() {
315+
long acc = 0;
316+
for (Chunk chunk : chunks) {
317+
RowFilter filter = RowFilter.eq("category", CATEGORY_VALUE)
318+
.and(RowFilter.gt("price", PRICE_THRESHOLD));
319+
FilteredAggregate measure = Compute.filteredAggregate(chunk, filter, "measure");
320+
FilteredAggregate plain = Compute.filteredAggregate(chunk, filter, "plain");
321+
acc += measure.selectedRows() + measure.sum().longValue() + plain.sum().longValue();
322+
}
323+
return acc;
324+
}
325+
277326
/// Hand-fused control for [#fusedFilteredSumAlp()]: the obvious developer loop a fused kernel must
278327
/// match — `for i: if (price.getDouble(i) > 500) acc += measure.getLong(i)` per chunk, with no
279328
/// off-heap bitmap. Decodes each price and (when selected) each measure through the accessor.

0 commit comments

Comments
 (0)