Skip to content

Commit 145791c

Browse files
dfa1claude
andcommitted
perf(compute): dict code-scan lane in filteredAggregate
Extends the DictFilter lane to the multi-column kernel behind the Calcite boundary aggregate push-down: a filter that is a single comparison leaf over a dict-encoded column (bare or wrapped in a one-element AND) scans raw u8 codes per chunk run instead of decoding every row through the per-element RowPredicate. COUNT(*) folds as a bare count of code matches; the full fold mirrors the kernel's own lanes exactly (widenLong + unsigned-aware min/max in the long domain, Double.compare order and Float extreme boxing in the double domain). Multi-leaf filters, null tests, and non-long/double aggregates decline to the unchanged RowPredicate path. fusedFilteredAggregateDict (100M rows): 982.5 -> 178.3 ms/op (~5.5x). The gap to the sum lane's 25.5 ms is the fuller per-match fold (sum, min, max, two counters) keeping the loop body scalar — recorded in ADR 0013 as an optional follow-up. The filteredAggregate oracle test now randomly dict-encodes its filter columns, sweeping the lane against the unchanged brute-force reference, plus pinned corners: COUNT(*) over codes, u64 unsigned aggregate order, f32 Float boxing, one-leaf AND unwrap. Also drafts ADR 0019 (Proposed): the columnar transducer facade — declarative column-bound stages compiled to one fused pass, preserving the lane-dispatch model this change extends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a27fb0b commit 145791c

8 files changed

Lines changed: 723 additions & 19 deletions

File tree

TODO.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,14 @@ Per-encoding gotchas:
9696
interfaces) were built then removed for the fused single-pass kernels — no intermediate bitmap.
9797
Encoded-domain value specialization was measured as a no-win (decode is dispatch-bound, see
9898
`forLoopDictEncoded`); the real dict lever — the monomorphic code-segment scan — shipped as the
99-
`DictFilter` lane in `FusedFilterSum` (762 → 25.5 ms/op on the 100M-row `fusedFilteredSumDict`).
100-
Next: (1) extend the `DictFilter` code-scan lane to `FusedFilterAggregate` (the Calcite boundary
101-
push-down kernel still reads dict filters through the per-element accessor; its single-leaf case
102-
can reuse the run-based scan directly); (2) the columnar transducer façade (ADR 0019).
99+
`DictFilter` lane in both kernels (`fusedFilteredSumDict` 762 → 25.5 ms/op ≈ 30×;
100+
`fusedFilteredAggregateDict` 983 → 178 ms/op ≈ 5.5×, single-leaf filters and `COUNT(*)`).
101+
Next: (1) the columnar transducer façade — [ADR-0019](docs/adr/0019-columnar-transducer-facade.md)
102+
drafted (Proposed): declarative column-bound stages compiled to one fused pass, multi-aggregate
103+
folds for the Calcite boundary tier; review, then implement. (2) Optional: specialize the dict
104+
aggregate lane's no-null long fold to close the 178 → 25 ms gap if the boundary tier needs it;
105+
(3) extend the dict lane to multi-leaf `AND`s (dict leaf as the driving scan, residual leaves
106+
tested per match).
103107

104108
## Encodings
105109

docs/adr/0013-compute-primitives.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,14 @@ backing segment — no per-element `findChunk` binary search, no broadcast-guard
8585
| `fusedFilteredSumDict` before (accessor chain per row) | 762.5 ± 7.4 ms/op |
8686
| `forLoopDictSegment` (raw segment scan ceiling) | 24.8 ms/op |
8787
| `fusedFilteredSumDict` after (`DictFilter` lane) | 25.5 ± 2.5 ms/op |
88-
89-
The lane reproduces the raw-scan ceiling (≈ 30×) with no measurable kernel overhead. It covers
90-
`Compute.filteredSum`; extending it to `FusedFilterAggregate`'s per-leaf path is the remaining
91-
follow-up (tracked in TODO).
88+
| `fusedFilteredAggregateDict` before (per-leaf `RowPredicate`) | 982.5 ± 30.2 ms/op |
89+
| `fusedFilteredAggregateDict` after (`DictFilter` lane) | 178.3 ± 4.1 ms/op |
90+
91+
The `filteredSum` lane reproduces the raw-scan ceiling (≈ 30×) with no measurable kernel overhead.
92+
The `filteredAggregate` lane (a single comparison leaf driving the same code scan, extended to the
93+
full `SUM`/`MIN`/`MAX`/count fold and `COUNT(*)`) lands at ≈ 5.5× — off the pure-scan ceiling
94+
because the per-match fold (sum, min, max, two counters) keeps its loop body scalar; specializing
95+
the common no-null long-aggregate fold is a possible follow-up if the boundary tier ever needs it.
9296

9397
## Context
9498

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# ADR 0019: Columnar transducer façade for compute
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-07-02
5+
- **Deciders:** project maintainer
6+
- **Supersedes:**
7+
- **Superseded by:**
8+
9+
## Context
10+
11+
ADR 0013 shipped the compute primitives as two fused kernels behind `reader.compute.Compute`:
12+
`filteredSum(filterColumn, predicate, aggColumn)` and `filteredAggregate(chunk, rowFilter,
13+
aggColumn)`. They are deliberately minimal — static entry points, package-private kernels — so
14+
early callers would not couple to a kernel shape before the ergonomics were settled. That bet paid
15+
off twice:
16+
17+
1. The original §1–§3 design (a materialized `Mask` between filter and reduce) was built and then
18+
**removed**: a selection bitmap between stages is strictly slower than fusing the stages into one
19+
pass. Whatever the public API looks like, execution must stay single-pass.
20+
2. The dict code-scan lane (`DictFilter`) showed that the wins now come from **encoding-aware
21+
structural dispatch**: the kernel inspects the filter column's shape (an offset slice over a
22+
dict view with `u8` codes) and swaps the whole loop, not the per-row callback. Measured on 100M
23+
rows: 762 → 25.5 ms/op for `filteredSum`, 983 → 178 ms/op for `filteredAggregate` (the fuller
24+
per-match fold — sum, min, max, two counters — keeps the aggregate lane off the pure-scan
25+
ceiling). A per-row lambda (`RowPredicate`) cannot express that — the lane needs to see the
26+
*description* of the filter, not a compiled `test(i)`.
27+
28+
Meanwhile the caller side is growing. The Calcite adapter (ADR 0018) invokes `filteredAggregate`
29+
from the boundary-chunk tier of the aggregate push-down; each new capability (a second aggregate
30+
column, `GROUP BY` push-down Phase 3, a projection) would today mean another bespoke static method
31+
on `Compute` and another hand-wired call site in the push-down rule. There is no way to *compose* a
32+
chunk-level computation — filter by two columns, fold three aggregates — without either a new
33+
kernel entry point per combination or falling back to per-row accessor loops, which the dict-lane
34+
numbers show cost 30× on encoded columns.
35+
36+
The goal is an ergonomic public compute API that composes, while preserving both hard-won execution
37+
properties: single-pass fusion and encoding-aware lane dispatch. Explicitly **not** a goal: a query
38+
engine. Join, sort, expression trees, and query planning belong to Calcite; this façade only makes
39+
the per-chunk leaf computation composable and fast (see `project_compute_facade`: the aim is faster
40+
Calcite push-down).
41+
42+
## Decision
43+
44+
Introduce a **columnar transducer**: an immutable, declarative description of a per-chunk pipeline
45+
— column-bound filter stages and aggregate folds — that a terminal operation compiles into one
46+
fused pass. "Transducer" in the original sense: the transformation is described independently of
47+
the source and reused across chunks; it is *not* the Clojure-style composition of per-element
48+
functions, precisely because per-element functions are what the kernels must never see.
49+
50+
Sketch (names to be settled during implementation):
51+
52+
```java
53+
ChunkFold fold = ChunkFold.builder()
54+
.filter("category", Predicate.eq(7L)) // stages are DATA: column + Predicate
55+
.filter("price", Predicate.gt(500.0))
56+
.aggregate("measure", Fold.SUM, Fold.MIN) // one or many folds, one or many columns
57+
.aggregate("volume", Fold.SUM)
58+
.build(); // lowered/validated once
59+
60+
FilteredAggregate measure = fold.apply(chunk).column("measure");
61+
```
62+
63+
Design rules, in force from the first commit:
64+
65+
- **Stages are data, never lambdas.** A filter stage is a `(column, Predicate)` pair — the same
66+
`Predicate` vocabulary as `RowFilter` and the zone maps. An aggregate stage is a `(column, fold
67+
kind)` pair. Because stages are inspectable, the terminal compile step can route each filter to
68+
its best lane (dict code-scan, primitive typed-accessor, generic boxing) exactly as
69+
`FusedFilterSum` does today — and future lanes (a `RunEnd` run-scan, a `Constant` short-circuit)
70+
drop in without touching the API.
71+
- **Terminal compilation, single pass.** `apply(chunk)` lowers every stage once (reusing
72+
`PrimitiveFilter` / `DictFilter` lowering — the single source of truth), chooses the driving lane,
73+
and folds everything in one scan with no intermediate bitmap or materialized selection. The
74+
n-ary `AND` of filter stages keeps the existing three-valued-logic semantics bit-identical to
75+
`filteredAggregate`.
76+
- **The existing `Compute` statics become thin wrappers** over one-stage pipelines and are kept:
77+
they are shipped API and the right call for the simple cases. The kernels stay package-private.
78+
- **Scope: fold-shaped terminals only.** Filters + aggregate folds (`SUM` / `MIN` / `MAX` /
79+
`COUNT`), matching what the Calcite boundary tier consumes. A row-materializing terminal
80+
(project/collect) is explicitly out of scope until a concrete consumer exists — it is where
81+
"façade" would start sliding toward "query engine".
82+
83+
The Calcite `VortexAggregatePushDownRule` boundary tier is the first consumer: it currently loops
84+
aggregate columns one `filteredAggregate` call per column; a multi-aggregate pipeline folds them in
85+
one scan of the filter column.
86+
87+
## Consequences
88+
89+
### Positive
90+
91+
- Composition without new entry points: k filters × m aggregates is one pipeline, one scan, one
92+
API — not `k×m` static methods.
93+
- Encoding-aware lanes keep working and keep winning: the declarative stages preserve exactly the
94+
information (`Predicate` + column shape) that made the 30× dict lane possible.
95+
- The Calcite boundary tier stops re-scanning the filter column once per aggregate column.
96+
- One lowering path shared with `RowFilter` / zone maps — no semantic drift between pruning and
97+
row-level evaluation.
98+
99+
### Negative
100+
101+
- A builder API is more surface than two statics; it must be documented and versioned as public
102+
API (the "small public APIs" rule says: start minimal, grow only on demonstrated need).
103+
- Multi-aggregate folds widen the kernel's inner loop (several accumulators per selected row);
104+
the single-aggregate fast shape must not regress — benchmark before accepting.
105+
106+
### Risks to manage
107+
108+
- **Lambda creep.** The moment a stage accepts a user lambda, lane dispatch dies (megamorphic
109+
per-row calls, the exact failure ADR 0013 measured). The API must not offer one, even as a
110+
convenience overload.
111+
- **Query-engine scope creep.** `GROUP BY`, joins, expressions stay in Calcite. The pipeline's
112+
vocabulary is bounded by what the kernels can fuse in one pass.
113+
- **Premature generality.** Ship with the Calcite boundary tier as the proving consumer; grow the
114+
vocabulary only when a second concrete consumer needs it.
115+
116+
## Alternatives considered
117+
118+
- **`java.util.stream.Stream` over rows.** Idiomatic, but value-level: per-row boxing and
119+
megamorphic per-element calls — the measured 30× loss on encoded columns — and no way to see the
120+
predicate's description for lane dispatch or zone pruning.
121+
- **Clojure-style function transducers** (composed `(acc, row) -> acc` reducers). Same per-element
122+
dispatch problem; composition happens at the wrong level (values, not columns).
123+
- **Keep growing `Compute` statics.** No composition; every filter/aggregate combination is a new
124+
method and a new hand-fused kernel; the Calcite rule accumulates call-site switches. This is the
125+
status quo the façade replaces.
126+
- **Expose the kernels directly.** Couples callers to loop shapes that ADR 0013 already replaced
127+
once (Mask → fused); the package-private kernel rule exists to keep that freedom.
128+
129+
## References
130+
131+
- [ADR 0013](0013-compute-primitives.md) — the fused kernels, the removed `Mask` design, and the
132+
measured dict code-scan results this façade must preserve.
133+
- [ADR 0018](0018-calcite-sql-adapter.md) — the aggregate push-down boundary tier, the first
134+
consumer.
135+
- `reader.compute.DictFilter` — the encoding-aware lane whose dispatch model the stage-as-data rule
136+
protects.

docs/adr/ADR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ the decision shipped in (blank = not yet shipped).
3333
| 0016 | vortex-arrow bridge module for Arrow interop | Proposed | |
3434
| 0017 | In-house FlatBuffers codegen + MemorySegment runtime | Accepted | |
3535
| 0018 | Apache Calcite SQL adapter — push-down source | Accepted | |
36+
| 0019 | Columnar transducer façade for compute | Proposed | |

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.dfa1.vortex.core.model.DType;
44
import io.github.dfa1.vortex.reader.Chunk;
55
import io.github.dfa1.vortex.reader.ReadRegistry;
6+
import io.github.dfa1.vortex.reader.RowFilter;
67
import io.github.dfa1.vortex.reader.VortexReader;
78
import io.github.dfa1.vortex.reader.array.Array;
89
import io.github.dfa1.vortex.reader.array.ByteArray;
@@ -16,6 +17,7 @@
1617
import io.github.dfa1.vortex.reader.array.OffsetDoubleArray;
1718
import io.github.dfa1.vortex.reader.array.OffsetLongArray;
1819
import io.github.dfa1.vortex.reader.compute.Compute;
20+
import io.github.dfa1.vortex.reader.compute.FilteredAggregate;
1921
import io.github.dfa1.vortex.reader.compute.Predicate;
2022
import io.github.dfa1.vortex.writer.VortexWriter;
2123
import io.github.dfa1.vortex.writer.WriteOptions;
@@ -245,6 +247,31 @@ public long fusedFilteredSumDict() {
245247
return acc;
246248
}
247249

250+
/// Fused dict-filtered multi-column aggregate: per chunk,
251+
/// [Compute#filteredAggregate(Chunk, RowFilter, String)] evaluates `category == 7` as a whole
252+
/// [RowFilter] and folds `measure`'s `SUM` / `MIN` / `MAX` / non-null count over the selected
253+
/// rows — the kernel behind the Calcite boundary-chunk aggregate push-down, on the same
254+
/// dict-filtered workload as [#fusedFilteredSumDict()]. Folds the selected count and sum into
255+
/// the return value so JMH cannot eliminate the loop.
256+
///
257+
/// Result (2026-07-02, 100M rows): 982.5 ± 30.2 ms/op through the per-leaf accessor path;
258+
/// 178.3 ± 4.1 ms/op with the `DictFilter` lane — ≈ 5.5×. The gap to [#fusedFilteredSumDict()]'s
259+
/// 25.5 ms is the fuller per-match fold (sum, min, max, two counters) whose heavier loop body
260+
/// stays scalar; closing it is a possible follow-up specialization.
261+
///
262+
/// @return the selected row count plus the sum of `measure` where `category == 7`, folded
263+
/// across the whole dataset
264+
@Benchmark
265+
public long fusedFilteredAggregateDict() {
266+
long acc = 0;
267+
for (Chunk chunk : chunks) {
268+
FilteredAggregate aggregate = Compute.filteredAggregate(
269+
chunk, RowFilter.eq("category", CATEGORY_VALUE), "measure");
270+
acc += aggregate.selectedRows() + aggregate.sum().longValue();
271+
}
272+
return acc;
273+
}
274+
248275
/// Hand-fused control for [#fusedFilteredSumAlp()]: the obvious developer loop a fused kernel must
249276
/// match — `for i: if (price.getDouble(i) > 500) acc += measure.getLong(i)` per chunk, with no
250277
/// off-heap bitmap. Decodes each price and (when selected) each measure through the accessor.

0 commit comments

Comments
 (0)