|
| 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. |
0 commit comments