Skip to content

Commit 2ba5488

Browse files
dfa1claude
andcommitted
feat(compute): fused multi-column filteredAggregate; migrate boundary fold off Mask
Compute.filteredAggregate(Chunk, RowFilter, aggColumn) evaluates a whole multi-column RowFilter (n-ary AND of column-bound predicates, three-valued) and folds the aggregate column in ONE pass — returning selectedRows, aggNonNullCount, sum, min and max with no intermediate selection bitmap. Leaves reuse PrimitiveFilter's predicate lowering and fall back to the generic kernel for composite/non-primitive predicates, so results are identical to the deprecated filter->Mask->reduce path (proven by an independent brute-force oracle test plus the unchanged boundary-fold suite). VortexTable.foldBoundaryZone now calls it instead of buildMask + Mask.and + per-leaf Compute.filter + masked reduce; buildMask/collectLeafMasks are deleted and the boundary path no longer touches the deprecated Mask (now production-dead, retained only behind its deprecation for now). Also fixes a latent bug surfaced en route: Values.valueAt had no Float16Array case, so SUM/MIN/MAX over an f16 column threw on BOTH the old and fused paths; f16 now boxes via getFloat and sums as a Double, matching the documented reduction semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a413c7 commit 2ba5488

7 files changed

Lines changed: 891 additions & 81 deletions

File tree

calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java

Lines changed: 27 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.github.dfa1.vortex.calcite;
22

3-
import io.github.dfa1.vortex.core.error.VortexException;
43
import io.github.dfa1.vortex.core.model.DType;
54
import io.github.dfa1.vortex.reader.ArrayStats;
65
import io.github.dfa1.vortex.reader.Chunk;
@@ -10,10 +9,9 @@
109
import io.github.dfa1.vortex.reader.VortexReader;
1110
import io.github.dfa1.vortex.reader.compute.Compare;
1211
import io.github.dfa1.vortex.reader.compute.Compute;
13-
import io.github.dfa1.vortex.reader.compute.Mask;
12+
import io.github.dfa1.vortex.reader.compute.FilteredAggregate;
1413
import io.github.dfa1.vortex.reader.compute.Predicate;
1514
import io.github.dfa1.vortex.reader.compute.ZoneReducer;
16-
import io.github.dfa1.vortex.reader.array.Array;
1715
import io.github.dfa1.vortex.reader.array.BoolArray;
1816
import io.github.dfa1.vortex.reader.array.ByteArray;
1917
import io.github.dfa1.vortex.reader.array.DoubleArray;
@@ -47,7 +45,6 @@
4745

4846
import java.io.IOException;
4947
import java.io.UncheckedIOException;
50-
import java.lang.foreign.Arena;
5148
import java.nio.file.Path;
5249
import java.util.ArrayList;
5350
import java.util.List;
@@ -191,10 +188,11 @@ public record FilteredFold(Number sum, Object min, Object max, Long nullCount, l
191188
/// Folds an aggregate over the rows a pushed `WHERE` `filter` selects, in two tiers (ADR 0013 §6).
192189
/// A zone the filter selects entirely (or excludes entirely) is folded from — or skipped via — its
193190
/// footer statistics with no decode; a boundary zone the filter only partially selects is decoded
194-
/// for the aggregate and filter columns, a row-level selection [Mask] is built by evaluating the
195-
/// filter through the compute kernels, and the aggregate is reduced over the masked rows. The two
196-
/// tiers combine into one fold, so the caller answers the filtered aggregate while decoding only
197-
/// the boundary zones.
191+
/// for the aggregate and filter columns and reduced in one fused pass — the whole filter and the
192+
/// aggregate fold evaluated together via
193+
/// [Compute#filteredAggregate(Chunk, RowFilter, String)], with no intermediate selection bitmap.
194+
/// The two tiers combine into one fold, so the caller answers the filtered aggregate while decoding
195+
/// only the boundary zones.
198196
///
199197
/// `aggColumn` is the column whose values are reduced over the selected rows (`null` for
200198
/// `COUNT(*)`, which needs only the selected row count). The result is empty — abandoning to a
@@ -317,79 +315,30 @@ public Optional<FilteredFold> filteredFold(RowFilter filter, String aggColumn) {
317315
}
318316

319317
/// Decodes one boundary chunk and folds the aggregate over the rows the filter selects in it,
320-
/// contributing to the running [Fold]. The chunk and a fresh confined [Arena] for the mask
321-
/// bitmaps are released per zone via try-with-resources, so nothing outlives the reduction.
318+
/// contributing to the running [Fold]. The chunk is released per zone via try-with-resources, so
319+
/// nothing outlives the reduction.
322320
///
323-
/// `SUM` is guarded: the compute kernel throws on a non-numeric aggregate column, so a thrown
324-
/// [VortexException] disables the sum (mirroring a zone that records no usable sum) rather than
325-
/// failing the whole fold — a `MIN`/`MAX` over the same column still folds. The aggregate
326-
/// column's null count among the selected rows is the selected count minus the non-null count.
327-
@SuppressWarnings("removal") // transitional — uses the deprecated Mask until the fused multi-column filteredReduce lands
321+
/// The whole filter — an n-ary `AND` of column-bound [Predicate] leaves — and the aggregate fold
322+
/// run in one fused pass over the chunk's rows via
323+
/// [Compute#filteredAggregate(Chunk, RowFilter, String)], with no intermediate selection bitmap.
324+
/// `SUM` is guarded: the fused kernel reports a `null` sum for a non-numeric aggregate column,
325+
/// which disables the sum (mirroring a zone that records no usable sum) rather than failing the
326+
/// whole fold — a `MIN`/`MAX` over the same column still folds. The aggregate column's null count
327+
/// among the selected rows is the selected count minus the non-null count.
328328
private static void foldBoundaryZone(VortexReader reader, int zone, List<String> columns,
329329
RowFilter filter, String aggColumn, Fold fold) {
330-
try (Chunk chunk = reader.decodeChunk(zone, columns);
331-
Arena maskArena = Arena.ofConfined()) {
332-
Mask mask = buildMask(chunk, filter, maskArena);
333-
long selected = mask.trueCount();
334-
fold.keptRows += selected;
330+
try (Chunk chunk = reader.decodeChunk(zone, columns)) {
331+
FilteredAggregate aggregate = Compute.filteredAggregate(chunk, filter, aggColumn);
332+
fold.keptRows += aggregate.selectedRows();
335333
if (aggColumn == null) {
336334
return;
337335
}
338-
Array aggArray = chunk.column(aggColumn);
339-
if (fold.sumUsable) {
340-
try {
341-
fold.addSum(Compute.sum(aggArray, mask));
342-
} catch (VortexException e) {
343-
fold.sumUsable = false; // non-numeric agg column — sum unanswerable, like a missing zone sum
344-
}
345-
}
346-
fold.addMin(Compute.min(aggArray, mask));
347-
fold.addMax(Compute.max(aggArray, mask));
348-
fold.addNulls(selected - Compute.count(aggArray, mask));
349-
}
350-
}
351-
352-
/// Builds the row-level selection [Mask] for a boundary chunk by evaluating `filter` through the
353-
/// compute kernels: each comparison or null-test leaf becomes a [Predicate] filtered against its
354-
/// column's decoded array, and an n-ary `AND` intersects the per-leaf masks (a row is selected
355-
/// only when every leaf accepts it). The kernels apply SQL three-valued logic, so a null row
356-
/// never satisfies a value predicate — matching the fold's null semantics.
357-
@SuppressWarnings("removal") // transitional — uses the deprecated Mask until the fused multi-column filteredReduce lands
358-
private static Mask buildMask(Chunk chunk, RowFilter filter, Arena arena) {
359-
long n = chunk.rowCount();
360-
List<Mask> masks = new ArrayList<>();
361-
collectLeafMasks(chunk, filter, arena, masks);
362-
if (masks.isEmpty()) {
363-
return Mask.allTrue(n); // an AND with no leaves constrains nothing (never reached for a boundary)
364-
}
365-
if (masks.size() == 1) {
366-
return masks.getFirst();
367-
}
368-
// Fold the per-leaf masks with the reader's Mask.and combinator (logical intersect): a row
369-
// survives only when every leaf accepts it (the n-ary AND). The combinator allocates the
370-
// result bitmap off-heap from this boundary zone's arena.
371-
Mask result = masks.getFirst();
372-
for (int k = 1; k < masks.size(); k++) {
373-
result = result.and(masks.get(k), arena);
374-
}
375-
return result;
376-
}
377-
378-
/// Walks the filter tree, evaluating each leaf into a per-column [Mask] and collecting them into
379-
/// `out`; the conjuncts of an n-ary `AND` are flattened so [#buildMask] intersects them together.
380-
/// A [RowFilter.Column] already holds the exact compute [Predicate], so the kernel runs it
381-
/// directly against the bound column — no translation seam, since `>=`, `<=` and `<>` are now
382-
/// first-class predicate variants.
383-
@SuppressWarnings("removal") // transitional — uses the deprecated Mask and Compute.filter until the fused multi-column filteredReduce lands
384-
private static void collectLeafMasks(Chunk chunk, RowFilter filter, Arena arena, List<Mask> out) {
385-
switch (filter) {
386-
case RowFilter.And(var parts) -> {
387-
for (RowFilter part : parts) {
388-
collectLeafMasks(chunk, part, arena, out);
389-
}
390-
}
391-
case RowFilter.Column(var col, var predicate) ->
392-
out.add(Compute.filter(chunk.column(col), predicate, arena));
336+
// A null sum marks a non-numeric aggregate column — unanswerable, like a missing zone sum;
337+
// addSum(null) disables only the sum, leaving MIN/MAX to fold.
338+
fold.addSum(aggregate.sum());
339+
fold.addMin(aggregate.min());
340+
fold.addMax(aggregate.max());
341+
fold.addNulls(aggregate.selectedRows() - aggregate.aggNonNullCount());
393342
}
394343
}
395344

@@ -622,9 +571,9 @@ private static boolean isUnsigned(DType.Struct struct, String column) {
622571
return idx >= 0 && struct.fieldTypes().get(idx) instanceof DType.Primitive p && p.ptype().isUnsigned();
623572
}
624573

625-
/// Whether `column` is a floating-point primitive, whose compute-kernel compare ([#buildMask])
626-
/// sorts NaN as the maximum and so cannot soundly build a boundary row mask (the fold abandons
627-
/// such a filter column to the NaN-correct scan).
574+
/// Whether `column` is a floating-point primitive, whose compute-kernel compare
575+
/// ([Compare#values(Object, Object, DType)]) sorts NaN as the maximum and so cannot soundly fold a
576+
/// boundary partition (the fold abandons such a filter column to the NaN-correct scan).
628577
private static boolean isFloating(DType.Struct struct, String column) {
629578
int idx = struct.fieldNames().indexOf(column);
630579
return idx >= 0 && struct.fieldTypes().get(idx) instanceof DType.Primitive p && p.ptype().isFloating();

reader/src/main/java/io/github/dfa1/vortex/reader/compute/Compute.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.dfa1.vortex.reader.compute;
22

3+
import io.github.dfa1.vortex.reader.Chunk;
4+
import io.github.dfa1.vortex.reader.RowFilter;
35
import io.github.dfa1.vortex.reader.array.Array;
46

57
import java.lang.foreign.Arena;
@@ -87,6 +89,32 @@ public static Number filteredSum(Array filterColumn, Predicate predicate, Array
8789
return FusedFilterSum.filteredSum(filterColumn, predicate, aggColumn);
8890
}
8991

92+
/// Evaluates `filter` over `chunk` and folds `aggColumn` over the rows it selects, in one fused
93+
/// pass — the multi-column counterpart of [#filteredSum(Array, Predicate, Array)] and the
94+
/// replacement for building per-leaf [Mask]s, intersecting them, and reducing under the result.
95+
///
96+
/// The `filter` is the whole chunk predicate: an n-ary `AND` of column-bound [Predicate] leaves
97+
/// (or a single leaf). A row is selected only when every leaf accepts it under SQL three-valued
98+
/// logic — a null in any filter column rejects that leaf. Over the selected rows whose aggregate
99+
/// value is non-null, the kernel folds the aggregate column's `SUM`, `MIN`, `MAX` and non-null
100+
/// count in the same scan, with no intermediate bitmap.
101+
///
102+
/// `aggColumn` may be `null` for a `COUNT(*)`-style fold that only counts selected rows; the
103+
/// returned [FilteredAggregate]'s aggregate fields are then empty. `SUM` is `null` for a
104+
/// non-numeric aggregate column (an unanswerable sum); `MIN` / `MAX` are `null` when no selected
105+
/// row is non-null. The aggregate's null count among the selected rows is
106+
/// `selectedRows − aggNonNullCount`.
107+
///
108+
/// @param chunk the decoded chunk holding the filter and aggregate columns
109+
/// @param filter the whole chunk predicate to evaluate
110+
/// @param aggColumn the column to reduce over the selected rows, or `null` to count rows only
111+
/// @return the fold over the rows `filter` selects
112+
public static FilteredAggregate filteredAggregate(Chunk chunk, RowFilter filter, String aggColumn) {
113+
Objects.requireNonNull(chunk, "chunk");
114+
Objects.requireNonNull(filter, "filter");
115+
return FusedFilterAggregate.aggregate(chunk, filter, aggColumn);
116+
}
117+
90118
/// Counts the selected non-null values of `array`.
91119
///
92120
/// Pairs with the (deprecated) [Mask]; transitional pending the fused reductions such as
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.github.dfa1.vortex.reader.compute;
2+
3+
/// The result of a fused multi-column filtered aggregation over one chunk, returned by
4+
/// [Compute#filteredAggregate(io.github.dfa1.vortex.reader.Chunk, io.github.dfa1.vortex.reader.RowFilter, String)].
5+
///
6+
/// The fold covers exactly the rows the whole [io.github.dfa1.vortex.reader.RowFilter] selects. The
7+
/// aggregate fields are populated only when an aggregate column was given; with no aggregate column
8+
/// (a `COUNT(*)`) only [#selectedRows()] is meaningful and the rest are zero / `null`.
9+
///
10+
/// Null semantics mirror the reductions: `SUM` over zero selected non-null rows is the additive
11+
/// identity (`0`), while `sum` is `null` only when the aggregate column is non-numeric (an
12+
/// unanswerable sum); `MIN` / `MAX` are `null` when no selected row is non-null. The aggregate
13+
/// column's null count among the selected rows is `selectedRows − aggNonNullCount`.
14+
///
15+
/// @param selectedRows the number of rows the whole filter selected
16+
/// @param aggNonNullCount the number of selected rows whose aggregate value is non-null (`0` when
17+
/// there is no aggregate column)
18+
/// @param sum the `SUM` over the selected non-null rows ([Long] for an integer aggregate,
19+
/// [Double] for a floating one), `null` when there is no aggregate column or it
20+
/// is non-numeric
21+
/// @param min the `MIN` over the selected non-null rows under the column's dtype-aware
22+
/// order, or `null` when none contributed
23+
/// @param max the `MAX` over the selected non-null rows, or `null` when none contributed
24+
public record FilteredAggregate(long selectedRows, long aggNonNullCount, Number sum, Object min, Object max) {
25+
}

0 commit comments

Comments
 (0)