- Status: Accepted, implemented — with §1–§3 superseded in implementation (see the
Implementation note below). Built in
reader.compute: §4 (Predicate), §5 (RowFilterunified overPredicate— aRowFilter.Columnbinds a column to a shared publicPredicate, the same predicate compiled against zone-map stats for pruning and against the decoded array for the boundary fold), and §6 (zone-map aggregate push-down, both the whole-zone and boundary-zone tiers). §1 (Mask) and §2/§3 (theFilterKernel/MapKernel/ReduceKernelinterfaces and the mask-based no-materialize contract) were built and then removed: the shipped design is the fused single-pass kernelsCompute.filteredSum/filteredAggregate, which fold filter and reduce in one scan with no intermediate selection bitmap — a stronger no-materialize guarantee than aMaskcould give. Deferred to their own ADRs: encoded-domain kernel specialization (perf escalation — pushing the predicate into the ALP/FoR/Dict integer domain without decoding) and the ergonomic façade (a columnar transducer, ADR 0019). - Date: 2026-06-15
- Deciders: project maintainer
- Supersedes: —
- Superseded by: —
- Related: ADR 0002 — Pluggable DType, Layout, and Compute, ADR 0005 — Vector API adoption, ADR 0010 — Lazy decode, ADR 0012 — Zero-copy layout decoding
The primitives shipped, but §1–§3 as drawn below did not survive the perf work. The Mask sealed
type, the FilterKernel / MapKernel / ReduceKernel interfaces, and the mask-based
no-materialize contract were implemented, benchmarked, and then removed. A materialized selection
Mask is a positional bitmap the reduce must re-scan — strictly slower than fusing the filter and
the reduce into one pass. The shipped surface is two fused kernels behind the minimal public
Compute entry point, with everything else package-private:
Compute.filteredSum(filterColumn, predicate, aggColumn)— one filter column, one summed column.Compute.filteredAggregate(chunk, filter, aggColumn)— an n-aryANDRowFilterand aSUM/MIN/MAX/ non-null-count fold over the rows it selects.
Both fold filter and reduce in a single scan with no intermediate bitmap, honoring §3's
no-materialize intent more strictly than a Mask pipeline would; each is type-specialized into
boxing-free long / double lanes with a generic boxing fallback. MapKernel was never built (no
consumer). The Predicate (§4) and RowFilter unification (§5) vocabulary is unchanged and is what
these kernels consume. The sections below are preserved as the original proposal; read §1–§3 as
history, not as the built design.
ComputeKernelBenchmark (100M rows, ≈ 800 MB/column, memory-bound, Apple silicon, JMH avg time):
| Benchmark | Score |
|---|---|
forLoopFilteredSum — hand-fused accessor loop (price > 500 ALP filter, FoR measure sum) |
421.4 ms/op |
fusedFilteredSumAlp — today's Compute.filteredSum, same predicate |
443.9 ms/op |
The fused kernel carries a ≈ 5 % overhead over the naive loop because it still decodes every element
through the typed accessor before comparing — there is no per-row win yet, by design. This is the
control the encoded-domain specialization must beat: compare and reduce in the ALP/FoR/Dict integer
domain, below the naive-loop number. Note the fused kernel is still ≈ 1.5× faster than the removed
Mask two-pass path it replaced — the overhead here is only against a hand-fused single loop.
A fail-fast probe measured whether encoded-domain value comparison actually wins, per encoding:
| Per-encoding count baseline | Score | Data read |
|---|---|---|
forLoopPlainControl (i64, no decode) |
46.4 ms/op | 800 MB |
forLoopForLong (FoR + ref) |
46.7 ms/op | 800 MB |
forLoopAlpDouble (ALP × f × e) |
46.5 ms/op | 800 MB |
forLoopDict (gather values[code]) |
≈ 420–530 ms/op | 100 MB codes |
forLoopDictEncoded (compare codes, no gather) |
≈ 520 ms/op | 100 MB codes |
The finding is negative and it is why encoded-domain specialization stays deferred: ALP and FoR are
memory-bound — their decode arithmetic is free under bandwidth, identical to a plain i64 scan, so
there is nothing for an integer-domain compare to save. Dict is the only decode with real overhead
(≈ 11× a plain scan while reading 8× less data), but comparing codes instead of gathering values
does not help (forLoopDictEncoded ties forLoopDict): the gather is not the bottleneck — the
per-element accessor dispatch is, and both paths pay it. So the lever for dict is a monomorphic /
vectorized segment scan of the codes, not the encoded-domain compare this ADR envisioned. Encoded
values buy a win only once the value path is compute-bound, which at these column widths it is not.
That dict lever shipped (2026-07-02) as the DictFilter code-scan lane in FusedFilterSum: the
predicate is lowered against the value pool once (through the same PrimitiveFilter lowering as the
primitive lanes), then the raw u8 codes are scanned per ChunkedByteArray child directly from the
backing segment — no per-element findChunk binary search, no broadcast-guard modulo, no gather:
Dict SUM(measure) WHERE category = 7, 100M rows |
Score |
|---|---|
fusedFilteredSumDict before (accessor chain per row) |
762.5 ± 7.4 ms/op |
forLoopDictSegment (raw segment scan ceiling) |
24.8 ms/op |
fusedFilteredSumDict after (DictFilter lane) |
38.1 ± 9.2 ms/op (3 forks; 28–57 per fork) |
fusedFilteredAggregateDict before (per-leaf RowPredicate) |
982.5 ± 30.2 ms/op |
fusedFilteredAggregateDict after (DictFilter lane) |
178.3 ± 4.1 ms/op |
fusedFilteredAggregateDict after profile-guided fold fix |
45.5 ± 1.4 ms/op (3 forks) |
fusedFilteredAggregateMulti before (2-leaf AND × 2 aggregates, per-leaf path) |
2269.1 ± 19.8 ms/op |
fusedFilteredAggregateMulti after the multi-leaf driving scan |
201.3 ± 1.6 ms/op (3 forks) |
The filteredSum lane runs at ≈ 20× (best forks touch the raw-scan ceiling at ≈ 30×; see the
variance note below). The filteredAggregate lane (a comparison leaf driving the same code scan,
extended to the full SUM/MIN/MAX/count fold and COUNT(*)) first landed at ≈ 5.5×;
async-profiler + PrintInlining then showed the residual was NOT the fold arithmetic but a
bimorphic aggregate read — the fold shared NumericColumns.widenLong with the match-table pool
evaluation, so the merged receiver profile compiled the per-match read as a bimorphic guard with
partially devirtualized segment reads. Giving the fold its own private read (its profile only ever
sees aggregate receivers) brought the lane to ≈ 22×. Lesson recorded: shared value-read helpers
between a cold setup path and a hot loop poison the receiver profile; hot folds read through their
own call sites.
The multi-leaf extension then generalized the lane: any AND holding a dict comparison leaf uses
that leaf to DRIVE the scan, lowers the remaining leaves once through the row loop's own
leafTest, and tests them only on code matches — 2269 → 201 ms/op (≈ 11×) for
category = 7 AND price > 500 over two aggregate calls, with the single-leaf shapes unchanged.
Measurement-discipline note (2026-07-04): single-fork numbers on these kernels carry heavy
run-to-run C2 variance — fusedFilteredSumDict spans 28–57 ms/op across JVM launches (inlining
luck; one fork deoptimized mid-run), and the previously recorded 25.5 ms and 36.2 ms were lucky
forks. All numbers above are 3-fork means (-f 3); recorded results in this repo should be
multi-fork from now on.
ADR 0010 establishes that the reader defers transform work until access
(LazyAlpDoubleArray, lazy FoR / ZigZag, lazy Chunked / Dict per ADR 0012).
The win lands at the encoding layer: chunks are skipped via zone maps,
dictionaries return code-vector views, ALP / FoR / ZigZag arrays carry the
encoded segment + transform constants without allocating a decoded buffer.
The lazy infrastructure stops at the Array boundary. Once a caller pulls
a chunk out of the scan, the natural next step is filter / project / reduce.
Today the only way to do that is element-by-element via the per-type
accessor (DoubleArray.getDouble, LongArray.getLong, …) or by forcing
materialization through ArraySegments.of(Array). Neither composes:
- Per-element accessors fight loop fusion — JIT cannot see across user code
boundaries, so
filterthensumdecode twice. - Forced materialization discards the lazy gain. A filter that retains 1% of rows still pays the full decode cost the moment a downstream stage asks for a buffer.
External libraries (Stream API, Clojure-style transducers, Fungoid-style pipelines) compose nicely at the user level but cannot solve this, because they consume already-decoded values. The fusion has to happen inside the compute layer, on the encoded representation, before the user-level abstraction touches anything.
Rust's vortex-array ships compute kernels for exactly this reason —
compare.rs, filter.rs, take.rs, slice.rs, between.rs, nan_count.rs,
sum, min_max — each operating directly on the encoded form when
possible. compare(ALPArray, scalar) encodes the scalar into the ALP
integer domain and compares ints; the doubles never exist. That is the
shape vortex-java needs.
This ADR defines the primitives required to support that shape. The choice of user-facing API (transducer, Stream, builder DSL) is deferred to a later ADR — primitives first, syntax later.
Introduce a small set of compute primitives, intended for a future
vortex-compute module:
public sealed interface Mask permits AllTrue, AllFalse, RangeMask, BitmapMask {
long length();
long trueCount();
boolean get(long i);
}AllTrue/AllFalseare zero-allocation singletons sized via factory.RangeMask(start, end)represents a contiguous slice — produced byLIMIT/SLICEoperators.BitmapMaskwraps aMemorySegmentof validity bits — produced bycompare/between/is_nullkernels.
A Chunk returned by the scan carries an optional Mask. Successive
filter kernels intersect masks in place; downstream kernels honor the
mask (skip excluded positions during reduce, emit a smaller result for
take). Nothing materializes until a sink demands it.
public interface FilterKernel<A extends Array> {
Mask apply(A array, Mask current, Predicate predicate);
}
public interface MapKernel<A extends Array, B extends Array> {
B apply(A array, Mask current);
}
public interface ReduceKernel<A extends Array, R> {
R apply(A array, Mask current);
}- Kernels receive the input
Array(possibly a lazy variant), the current selection mask, and operator-specific parameters. - Implementations dispatch on the concrete
Arraysubtype via pattern switch (e.g.LazyAlpDoubleArray→ encoded-domain compare, fallback arrays → materialized path). - Output is an
ArrayorMaskof the same length as the input — positional alignment is preserved through the pipeline so masks remain meaningful across stages.
A kernel that operates on a lazy Array variant must not call
ArraySegments.of(array) unless every fallback path has been exhausted.
The contract:
- Encoded-domain implementation if the predicate or reduction can be
pushed through the transform. (
compare(LazyAlpDoubleArray, scalar)encodes the scalar, compares longs.) - Streaming per-element implementation using the accessor if (1) is not possible. Allocates only the result.
- Forced materialization only as a last resort, gated by a debug log / counter so regressions are visible.
This is the rule that prevents the lazy gain from leaking. A new kernel that breaks it is a bug, not a perf concern.
public sealed interface Predicate {
record Eq(Object value) implements Predicate {}
record Lt(Object value) implements Predicate {}
record Gt(Object value) implements Predicate {}
record Between(Object lo, Object hi) implements Predicate {}
record IsNull() implements Predicate {}
record And(Predicate left, Predicate right) implements Predicate {}
record Or(Predicate left, Predicate right) implements Predicate {}
}Predicates are sealed records so kernels can dispatch via pattern switch and lower into encoded-domain comparisons per encoding (e.g. ALP encodes the scalar to its integer domain once, then runs a long compare).
reader.RowFilter already pushes simple predicates into the scan to skip
chunks via zone maps. The new Predicate type is the input format
RowFilter will accept once it grows beyond the current Predicate
shape — same vocabulary used by both layers, so pushdown is just "the
same predicate compiled against zone-map stats instead of an array."
§5 is predicate push-down: skip zones whose min/max rule them out. The
vortex.stats (zoned) layout also enables aggregate push-down — answer
a reduction from the per-zone stats table without decoding the data
segment at all.
The stats table carries one row per zone. The writer emits MIN/MAX
today; NULL_COUNT and SUM are the next increment (Rust parity — Rust
fixtures emit exactly [MIN, MAX, NULL_COUNT] and, for numeric columns,
[MIN, MAX, SUM, NULL_COUNT], nothing else). Those four stats answer the
common reductions directly:
SUM(col)→ sum the per-zoneSUMcolumn.COUNT(col)→Σ zone_len − Σ NULL_COUNT(count of non-nulls).MIN/MAX→ reduce the per-zoneMIN/MAXcolumns.
A ReduceKernel therefore runs in two tiers, mirroring the predicate
case:
- Whole-zone tier — for every zone the predicate selects entirely (or with no predicate at all), fold the zone's contribution from the stats row. No data segment is touched.
- Residual tier — only zones the predicate partially selects fall back to the streaming per-element reduce (§3 contract), and only for those zones.
So a filter(...).sum(col) over a column where the filter prunes at zone
granularity becomes a read of the small stats table plus a streaming
reduce of the boundary zones — the same Predicate / reduction
vocabulary compiled against zone-map stats at tier 1 and against the
encoded array at tier 2.
This needs the scan to expose per-zone stats to the reduce kernel. The
decode path already exists: inspector ZonedStatsSchema reconstructs
the stats-table dtype and decodes the zones child; the scan would surface
the same per-zone rows to the kernel rather than (only) to the inspector.
- Filter / project / aggregate compose without materializing intermediates.
A
filter(close > 100).sum(volume)pipeline touches the close column's encoded i64s once and the volume column's encoded i64s once. - The lazy decoders introduced by ADR 0010 / 0012 become useful for more than single-column projection.
- User-facing API layer (transducer, Stream, fluent builder) is a thin wrapper — same primitives, multiple syntaxes possible.
- Test coverage is per-kernel, decoupled from any specific API surface.
- Aggregate push-down (§6) lets
SUM/COUNT/MIN/MAXbe answered from the zone-map stats table, skipping data decode entirely for whole zones — the payoff that motivates emittingNULL_COUNT+SUMzone stats on the writer side.
- Adds a new module (
vortex-compute) and a non-trivial type vocabulary. Kernel implementations grow combinatorially with(Array variant) × (Predicate variant); we'll need a default fallback path so unknown combinations still work. BitmapMaskintroduces a second memory representation alongside decoded buffers —ArrayStatsand zone-map plumbing need to learn about it.
- Kernel matrix explosion. Mitigate by writing one generic streaming
path that works for any
Arrayvia accessors, then specializing only the hot encodings (ALP, FoR, BitPacked, Dict). Specialization is a performance escalation, not a correctness requirement. - User-facing API churn. Without a decided façade (transducer vs
Stream vs builder), early callers of
vortex-computeend up depending on raw kernels and break when the façade lands. Mitigate by keeping kernels package-private until the façade ADR is accepted, exposing only a minimalComputeentry point. - Mask semantics under cascaded encodings. A mask produced against
LazyAlpDoubleArraymust still align positionally after the chain is unwrapped throughChunked/Dict. Test cross-encoding pipelines early.
Reuse Stream<Double> etc. with custom Spliterators that respect masks.
Rejected: Stream forces autoboxing on primitive specializations (no
DoubleStream.filter(DoublePredicate) that emits a packed mask), and the
internal Spliterator state isn't a natural place to carry encoded-domain
short-circuits. Worth offering as a convenience sink on top of the
kernels, not as the primary fusion mechanism.
The transducer composition shape is appealing — comp(map, filter, take)
returns a single reducing function. But transducers fuse at the value
level (one item at a time), which still forces per-element decode. Vortex
needs per-chunk, per-encoding fusion, which is a different abstraction.
Transducers (or a similar fluent API) can live on top of the kernels as
a syntax layer; they cannot replace the kernels.
ADR 0002 is marked Deferred and covers compute pluggability. This ADR covers the primitives that pluggable compute would plug into. We can ship the primitives now without committing to user-installable kernels. Pluggability is a later question — the no-materialize contract and predicate vocabulary stand on their own.
- Rust
vortex-arraycompute kernels:https://github.com/vortex-data/vortex/tree/develop/vortex-array/src/compute - Arrow C++ compute kernels documentation:
https://arrow.apache.org/docs/cpp/compute.html - Fungoid (transducer-inspired pipeline lib) as a possible façade layer:
https://github.com/dfa1/fungoid.js