diff --git a/.clang-tidy b/.clang-tidy
index 55411760..44850cdf 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -1,3 +1,9 @@
+# misc-use-internal-linkage is disabled below: the runtime's foo.cpp +
+# foo_internal.hpp split shares helpers across a few TUs through named *_detail
+# namespaces. Analyzing one TU the check can't see the cross-TU use and tells us
+# to make those `static` (which breaks the link); the genuinely TU-local helpers
+# interleaved in the same blocks would each need call-site requalification to
+# move to an anonymous namespace. Style only (inlining / symbol-table size).
Checks: >
-*,
bugprone-*,
@@ -26,7 +32,8 @@ Checks: >
-cppcoreguidelines-owning-memory,
-bugprone-easily-swappable-parameters,
-cppcoreguidelines-pro-type-union-access,
- -cppcoreguidelines-pro-type-vararg
+ -cppcoreguidelines-pro-type-vararg,
+ -misc-use-internal-linkage
CheckOptions:
# libstdc++ implementation headers are not portable include providers.
# Prefer the public standard-library header for every standard declaration.
diff --git a/README.md b/README.md
index 49c578f3..d93a809d 100644
--- a/README.md
+++ b/README.md
@@ -266,7 +266,8 @@ trades[update { (delta, gamma) = compute_greeks(price) }]
User-defined functions can already require a minimum input table schema with
`DataFrame<{ ... }>` parameter types. Declared columns must exist with the
-right types; extra columns are allowed:
+right types; extra columns are allowed and pass through, but cannot be named in
+the function body unless the parameter schema declares them:
```
fn top_two_salaries(df: DataFrame<{ salary: Int64 }>) -> DataFrame effects {} {
@@ -845,6 +846,7 @@ directory.
:schema
Show column names and types
:head [n] Show first n rows (default 10)
:peek Evaluate and compactly display an expression
+:explain Show the physical-plan capability without executing it
:describe [n] Schema + first n rows
:doc Show docs/signature for a binding or built-in
?name Shorthand for :doc
diff --git a/SPEC.md b/SPEC.md
index b9e7070d..0e0fef5b 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -3152,6 +3152,11 @@ than an exact-match type.
- `DataFrame<{ departmentId: Int, salary: Int }>` requires both columns; other
columns remain permitted.
+Only the declared fields are statically nameable inside the function. Permitted
+extra columns pass through unchanged, but the function cannot reference one by
+name unless it adds that column to its parameter schema. The open remainder is
+a validation allowance, not a source of dynamically discoverable column names.
+
This contract is checked at **call time**. A missing required column, or a
required column with the wrong type, is a call-time error that names the
parameter and the offending column. The same contract applies to a function's
diff --git a/include/ibex/ir/join_output.hpp b/include/ibex/ir/join_output.hpp
index d8c46f3c..dbc70767 100644
--- a/include/ibex/ir/join_output.hpp
+++ b/include/ibex/ir/join_output.hpp
@@ -48,6 +48,33 @@ struct JoinOutputColumn {
auto operator==(const JoinOutputColumn&) const -> bool = default;
};
+/// One equijoin key resolved against the ordered physical columns of both
+/// inputs. Names are a logical/schema concern; build and probe kernels consume
+/// these stable positions.
+struct JoinKeyColumns {
+ std::size_t left_index = 0;
+ std::size_t right_index = 0;
+
+ auto operator==(const JoinKeyColumns&) const -> bool = default;
+};
+
+/// The complete column-name resolution for one join: positional key bindings
+/// plus the authoritative output gather/rename plan. This is the join analogue
+/// of `ColumnNameMap`: consumers resolve names once, then share this value
+/// instead of independently looking them up or reconstructing output names.
+struct JoinColumnMapping {
+ /// Ordered physical layouts against which every position below was
+ /// resolved. A lazy child can consume predicate-only columns before the
+ /// join boundary; execution detects that change once and re-resolves the
+ /// complete mapping against the concrete tables.
+ std::vector left_input_names;
+ std::vector right_input_names;
+ std::vector keys;
+ std::vector output;
+
+ auto operator==(const JoinColumnMapping&) const -> bool = default;
+};
+
/// The single authority on a join's output column list and naming.
///
/// IR schema inference, the materialized interpreter, the chunked executor and
@@ -75,4 +102,14 @@ struct JoinOutputColumn {
const JoinSuffixPolicy& suffix = {})
-> std::expected, std::string>;
+/// Resolve every textual join key to its input position and compute the output
+/// plan through `plan_join_output`. Known schemas call this during physical
+/// planning; lazy/unknown schemas call it once when their concrete columns
+/// first reach the build/probe barrier.
+[[nodiscard]] auto resolve_join_columns(JoinKind kind, const std::vector& keys,
+ std::span left_names,
+ std::span right_names,
+ const JoinSuffixPolicy& suffix = {})
+ -> std::expected;
+
} // namespace ibex::ir
diff --git a/include/ibex/runtime/interpreter.hpp b/include/ibex/runtime/interpreter.hpp
index 118686fe..5a86a6c4 100644
--- a/include/ibex/runtime/interpreter.hpp
+++ b/include/ibex/runtime/interpreter.hpp
@@ -703,6 +703,18 @@ struct ExecutionContext {
/// chunked join, and the lazy-table keep-rows scan) and must agree.
bool parallel_join_probe = true;
+ /// Child subtrees the materialized-call fallback already built through the
+ /// physical path (`build_materialized_fallback`), keyed by node pointer.
+ /// Null for every ordinary call. When set, `interpret_node` returns the
+ /// pre-built table for a node listed here instead of recursing into it, so a
+ /// filtered or projected input to a fallback breaker keeps the fused
+ /// parallel scan `build_operator` gave it rather than being re-evaluated
+ /// whole-table and serial. Only the fallback node's direct children appear;
+ /// deeper descendants are interpreted normally. Not owned — the tables live
+ /// in the fallback frame for the duration of the `interpret_node` call.
+ const std::vector>* pre_materialized_children =
+ nullptr;
+
/// Look up a deferred scan by its plan (instance) name, or null if there is
/// no registry or no matching entry.
[[nodiscard]] auto deferred_scan(const std::string& name) const -> const DeferredScan* {
diff --git a/libs/data_gen/data_gen.ibex b/libs/data_gen/data_gen.ibex
index a5d99201..ade43564 100644
--- a/libs/data_gen/data_gen.ibex
+++ b/libs/data_gen/data_gen.ibex
@@ -17,16 +17,24 @@ extern fn gen_ticks(
volatility: Float64 = 0.5,
interval_ms: Float64 = 1000.0,
start_ts_ms: Int = 0
-) -> DataFrame from "data_gen.hpp";
+) -> DataFrame<{
+ timestamp: Timestamp,
+ symbol: String,
+ price: Float64,
+ volume: Int64
+}> from "data_gen.hpp";
// Gaussian random walk: value[0] = start, value[i] = value[i-1] + N(0, step_std).
-extern fn gen_walk(n: Int, start: Float64 = 0.0, step_std: Float64 = 1.0) -> DataFrame from "data_gen.hpp";
+extern fn gen_walk(n: Int, start: Float64 = 0.0, step_std: Float64 = 1.0)
+ -> DataFrame<{ value: Float64 }> from "data_gen.hpp";
// n iid samples from N(mean, stddev) in a single "value" column.
-extern fn gen_normal(n: Int, mean: Float64 = 0.0, stddev: Float64 = 1.0) -> DataFrame from "data_gen.hpp";
+extern fn gen_normal(n: Int, mean: Float64 = 0.0, stddev: Float64 = 1.0)
+ -> DataFrame<{ value: Float64 }> from "data_gen.hpp";
// n iid samples from Uniform[low, high) in a single "value" column.
-extern fn gen_uniform(n: Int, low: Float64 = 0.0, high: Float64 = 1.0) -> DataFrame from "data_gen.hpp";
+extern fn gen_uniform(n: Int, low: Float64 = 0.0, high: Float64 = 1.0)
+ -> DataFrame<{ value: Float64 }> from "data_gen.hpp";
// n sequential string ids "0" .. "(n-1)".
extern fn gen_ids(n: Int, prefix: String = "row") -> DataFrame from "data_gen.hpp";
@@ -34,4 +42,5 @@ extern fn gen_ids(n: Int, prefix: String = "row") -> DataFrame from "data_gen.hp
// Static reference data: one row per distinct symbol, with columns
// symbol, name, sector, currency, lot_size, tick_size. Deterministic (no RNG),
// so it joins against `gen_ticks` output on `symbol`.
-extern fn gen_reference(symbols: String = "AAPL,MSFT,GOOG") -> DataFrame from "data_gen.hpp";
+extern fn gen_ids(n: Int, prefix: String = "row")
+ -> DataFrame<{ id: String }> from "data_gen.hpp";
diff --git a/plans/README.md b/plans/README.md
index b860037b..810ef3d4 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -1,7 +1,8 @@
# Plans Index
-Status of every plan in this directory, grouped by lifecycle. Statuses
-re-verified against the source tree on **2026-08-27** (that pass also compacted
+Status of every plan in this directory, grouped by lifecycle. Statuses were
+initially re-verified against the source tree on **2026-08-27** (the
+kernel-pipeline entry was re-verified again on 2026-08-29); that pass compacted
the four largest plans — `parallelism-overview`, `query-shape-conformance`,
`runtime-multithreading`, `kernel-pipeline-execution` — plus
`owned-agg-per-chunk-barrier`, moving their measurement diaries to git history
@@ -15,13 +16,13 @@ history).
| Plan | Status | What's actually left |
|---|---|---|
| [beat-polars-plan.md](beat-polars-plan.md) | **Umbrella plan** for the multi-core push (proposed; §8 keeps the baseline record and dead ends). 2026-08-27 update corrected two stale q10 diagnoses (carried group fields optimized during discovery, ~−10.5%; the "36ms serial join build" was inclusive attribution). | Target: implied parallel fraction 44% → 60–65%. Workstreams W1 parallel inner join, W2 aggregate residue, W3 scheduler slice 2, W4 chunked `let` bindings, W5 small-query-tax guard. Points into pipelined-execution + runtime-multithreading + kernel-pipeline for mechanism. |
-| [kernel-pipeline-execution-plan.md](kernel-pipeline-execution-plan.md) | **Phase 2 complete** except `KernelContext` (deliberately unbuilt); **Phase 3 started** (executor-owned ordered handoff, islands dissolved into a pipeline mode); **Phase 4 construction ownership done** (backlog 116→6 breakers, plan describes 97% of real-work nodes) but **decomposition not started** — operators are unchanged, so the exit criterion is not met. Architectural successor: typed logical IR, physical pipelines, morsel executor, templated kernel library; not a JIT. | Next: split the join operator into scheduled `HashBuild`+`HashProbe` (join build-side split DONE 2026-08-25, q21 −8.5%; probe-as-pipeline-step BUILT off-by-default, fires on 1/22 queries); aggregate phase decomposition (blocked-first on an already-broken determinism constraint — `try_owned_pair` vs the serial path disagree bit-for-bit at ≥65536 rows, no test); port `Tail`/`TopK`/`FilterHead`/`FilterTail`; split `chunked.cpp` by ownership. |
+| [kernel-pipeline-execution-plan.md](kernel-pipeline-execution-plan.md) | **Phase 2 complete** except `KernelContext` (deliberately unbuilt); Phase 3 handoff/island/raw-thread work complete, with accounting and DOP/memory budgets deferred; Phase 4 construction ownership **and fan-out authority** done (backlog 116→6 breakers, plan describes 97% of real-work nodes). Streaming inner joins have typed `HashBuild`/`HashProbe` nodes and positional `JoinColumnMapping`. Streaming aggregates have positional `AggregateColumnMapping`, authoritative partition/finalize policy, and a typed Discovery → Accumulation → FinalOrdering → Emission hash-fallback chain. The serial coordinator invokes all four nodes through a bounded discovery transfer or explicit fused marker, with independent profile rows. Executor-seam mutations prove mappings, policies, and structural edges are consumed or rejected. Known closed schemas bind during planning; lazy/open schemas bind once at execution. Semi/anti retains its separate streaming operator. Architectural successor: typed logical IR, physical pipelines, morsel executor, templated kernel library; not a JIT. | Next: attach aggregate fan-out policy to each structural node, admit it phase by phase, then split `chunked.cpp` by ownership. |
| [benchmark-perf-priorities.md](benchmark-perf-priorities.md) | Living reference | P0–P2 resolved/landed; rolling min/max optimized. Open: suite trimming (pin sqlite + data.table frollapply cells, duckdb at 3 scales); P4 `tanh` deferred pending accuracy-vs-speed call; P3 ohlc scatter-bound (negative result recorded — don't re-attempt naive fusion); re-check rolling_mean on AWS after the July 2026 regression fix |
| [benchmark-coverage-plan.md](benchmark-coverage-plan.md) | ~95% done | #9 ClickHouse EWMA (needs arrayFold workaround); #10 DataFusion `fill_forward/backward` + `tf_asof_join` |
| [count-window-plan.md](count-window-plan.md) | Implemented (interpreter + codegen) | Per-call count/duration windows work (`__window_n`/`__window_ns` in lower.cpp + window.cpp), and the compiled path (`ibex_compile`) is at parity. Open: `window N rows` block syntax and tuple-field `update` inside `window` (interpreter doesn't support that combo either, so codegen correctly still rejects it). The old monotonic-deque follow-up for `rolling_min`/`rolling_max` is done. |
| [non-row-local-filter-plan.md](non-row-local-filter-plan.md) | Stage 1 shipped | `lag`/`lead`/`is_null` in filter work. Remaining: `rank(...)` in filter/select with `by`, explicit `order {}` context, rolling functions in filter (`price > rolling_mean(price)`) |
| [bigger-than-ram-plan.md](bigger-than-ram-plan.md) | Phase 4 bullet 1 of 4 done | Out-of-core execution. Done: chunked/streaming `read_parquet` (branch `chunked-parquet-read`; ~6.5× lower peak RSS, ~1.7× faster, verified local + AWS). Next: column projection pushdown, row-group stats pushdown, directory/Hive datasets (rest of Phase 4), then Phase 1 spill infrastructure (prerequisite for Phases 2–3, 6–7: external sort, out-of-core join, adaptive spill selection) |
-| [runtime-multithreading-plan.md](runtime-multithreading-plan.md) | Phase 1 landed and **ON by default** (parallel islands = two-phase filter + metadata-tail, every shape beats serial); Phase 3a complete; Phase 3b first source slice landed; Phase 4 items 1–2 landed, item 3 RETIRED (the join gap was Categorical probe keys hashed as *text*, not threading), item 4 part-done. **Nomenclature: `IBEX_THREADS` → `IBEX_CORES`; `IBEX_PARALLEL` removed (serial is `IBEX_CORES=1`).** | The PDS-H multithreading gap is **Phase 4 (parallel barriers)**, not sources — only 5/22 queries even form an island. Next: group-by string/int/generic hash paths + `distinct`; the LazyTable Synchronization Contract (written, unimplemented — Phase 3b's foundation); Phase 2 deterministic RNG (designed, not started). Re-measure at a larger scale before ranking — the threading share of a gap grows with row count. |
+| [runtime-multithreading-plan.md](runtime-multithreading-plan.md) | Row-local morsel-parallel pipelines are **ON by default**; Phase 3a is complete; Phase 3b's first source slice landed; Phase 4 items 1–2 landed, item 3 RETIRED (the join gap was Categorical probe keys hashed as *text*, not threading), item 4 part-done. **Nomenclature: `IBEX_THREADS` → `IBEX_CORES`; `IBEX_PARALLEL` removed (serial is `IBEX_CORES=1`).** | The PDS-H multithreading gap is **parallel barriers**, not sources. Next: group-by string/int/generic hash paths + `distinct`; the LazyTable Synchronization Contract (written, unimplemented — Phase 3b's foundation); Phase 2 deterministic RNG (designed, not started). Re-measure at a larger scale before ranking — the threading share of a gap grows with row count. |
| [join-perf-plan.md](join-perf-plan.md) | Items 1–3 done (2026-07-14; q09 −23%, q13 −30%) | Join/group-by performance findings; see the file's Results section. beat-polars points here for join mechanism |
| [owned-agg-per-chunk-barrier-plan.md](owned-agg-per-chunk-barrier-plan.md) | High-cardinality partition-owned aggregation vs Polars streaming. q18 (single-Int64 `Sum`) largely closed by the async hot/cold rewrite (**−33%**); the parallel finalize merge landed for all owned paths (**−7%**); q21's ordered-run `Count` finalize + emit fusion landed (**−11.2% SF-4**). | q20/`PairIntKey` (the hot table doesn't help a scattered composite key — a per-partition `CardinalitySketch` is the candidate); q21's remaining wall is the per-chunk accumulate orchestration + a duplicate lineitem decode + a 40ms serial hash-join build. **Do NOT touch `part_count` or serial-`reserve` the maps** — both measured dead ends. |
| [grouped-chunkview-update-plan.md](grouped-chunkview-update-plan.md) | Mostly complete — `update …, by k` runs off an immutable `GroupedRowPlan` (CSR) instead of gather → per-group `Table` → scatter. Sub-plan of kernel-pipeline Phase 2. | Remaining materialized shapes: `rank`, variable-width ordered state, `window`-clause `lag`/`lead`. |
@@ -40,7 +41,6 @@ history).
| Plan | Notes |
|---|---|
-| [pipelined-execution-plan.md](pipelined-execution-plan.md) | **Phase 2 partially implemented.** Multi-chunk correctness, lazy row-group streaming, concurrent scan decoding, ordered source→map overlap, and a bounded streamed join-probe handoff landed. SF-1 improves 2.5–4.6% at 2–8 cores; SF-4 improves 0.6–3.7% at 4–8. The two-core SF-4 crossover (+6.9%) and q18 are explicit follow-ups. Open: progress-aware admission/backpressure and general breaker scheduling |
| [parallel-chunkview-output-plan.md](parallel-chunkview-output-plan.md) | ChunkView kernel output protocol under parallel execution — `update_table` stays the semantic/ownership authority, a direct kernel plans a field and writes one assigned range. Sub-plan of kernel-pipeline Phase 2 (see the "Parallel chunk updates" entries). |
| [radix-partitioned-groupby.md](radix-partitioned-groupby.md) | Noted, not built. High-cardinality group-by is memory-bound; radix partitioning remains a q18/q20 mechanism. Q10 no longer reaches the generic mixed-key ceiling (2026-08-27: FD reduction + discovery-time `First` gathering handle that shape). |
| [exists-subquery-plan.md](exists-subquery-plan.md) | Proposal: `exists(table_expr)` as a boolean subquery term — semi/anti/mark joins and the residual-predicate case |
diff --git a/plans/beat-polars-plan.md b/plans/beat-polars-plan.md
index d8e76e14..ada80017 100644
--- a/plans/beat-polars-plan.md
+++ b/plans/beat-polars-plan.md
@@ -4,11 +4,11 @@ Status: **proposed.** Written 2026-08-16 on branch `streaming-scan-units`, after
the pipelined-execution Phase 2 slices (streamed scan units, concurrent decode,
source→map pipeline, join-probe handoff) landed. This is the umbrella plan: it
sets the target, decomposes the remaining gap into workstreams with expected
-payoffs, and sequences them. It supersedes nothing — it points into
-`pipelined-execution-plan.md`, `runtime-multithreading-plan.md`, and
-`join-perf-plan.md` for mechanism (the former fourth input,
-`chunked-execution-plan.md`, was removed from the tree 2026-08-22; its
-open items live in the kernel-pipeline plan and bigger-than-ram).
+payoffs, and sequences them. It points into
+`kernel-pipeline-execution-plan.md`, `runtime-multithreading-plan.md`, and
+`join-perf-plan.md` for mechanism. The former pipelined- and chunked-execution
+plans were removed as superseded; their remaining work lives in the kernel
+pipeline plan and bigger-than-ram plan.
Absorbed `pds.md` (the 2026-08-11 status snapshot this grew out of) on
2026-08-22: §5 and §6 took its still-unique traps and dead ends, §8 keeps
its baseline record.
@@ -193,8 +193,8 @@ is, so every workstream is stated as "serial ms attacked".
## 2. Where the serial time is
Calling-thread self-ms summed over the suite at 8 cores, with worker help drawn
-(`pool_work / self`), from `pipelined-execution-plan.md` ("Where the serial
-time actually is", post-semi-join numbers):
+(`pool_work / self`), from the archived pipelined-execution measurement study
+(git history; post-semi-join numbers):
| operator | self ms | pool ms | worker help | verdict |
|---|---:|---:|---:|---|
@@ -512,7 +512,7 @@ W3.1 progress-aware admission ── next scheduler slice; fixes 2-core cl
W3.2 q18/q22 rundown ── decides W4's priority
W4 chunked let bindings ── design note first; largest structural risk
W3.3+ aggregate-output stages,
- general scheduler, island retirement
+ explicit breaker phases and general scheduler
W2.3 parallel gid ── only if still dominant after the above
```
diff --git a/plans/bigger-than-ram-plan.md b/plans/bigger-than-ram-plan.md
index ce5c55f6..4ff92f86 100644
--- a/plans/bigger-than-ram-plan.md
+++ b/plans/bigger-than-ram-plan.md
@@ -72,8 +72,8 @@ built on it.
Flag this as the one open design question worth confirming before writing
code: bespoke binary would be marginally faster to (de)serialize but is a
new format to maintain and test independently.
-- A byte-budget tracker (`IBEX_MAX_MEMORY`, mirroring the `IBEX_THREADS` knob
- design in `runtime-multithreading-plan.md`): unset/0 means unbounded — the
+- A byte-budget tracker (`IBEX_MAX_MEMORY`, following the current compute/pool
+ configuration split in `runtime-multithreading-plan.md`): unset/0 means unbounded — the
default, so existing benchmarks and RSS baselines in
`plans/benchmark-perf-priorities.md` are completely unaffected until a user
opts in. When set, operators accumulate in memory and spill only once the
diff --git a/plans/kernel-pipeline-execution-plan.md b/plans/kernel-pipeline-execution-plan.md
index 3d820c50..d03569dd 100644
--- a/plans/kernel-pipeline-execution-plan.md
+++ b/plans/kernel-pipeline-execution-plan.md
@@ -2,10 +2,14 @@
**Status: in migration.** Phase 0 resolved by disposition; Phase 1 landed
2026-08-22; Phase 2 complete except `KernelContext` (deliberately unbuilt);
-Phase 3 started; Phase 4 construction-ownership done, decomposition not started;
-Phase 5 not started. **Compacted 2026-08-27** — the ~40-entry Phase 2 per-commit
-diary is in git history at the pre-compaction commit's parent; the "Where Phase
-2 stands" table below is the current state.
+Phase 3's handoff/island/raw-thread work is complete, with accounting and
+DOP/memory budgets deferred; Phase 4 construction ownership and parallelism
+authority are done, and the targeted join and aggregate decomposition is
+complete; Phase 5 is in progress: fused logical node kinds are retired and the
+aggregate and streaming inner-join execution families are now outside the monolith. **Compacted
+2026-08-27** — the ~40-entry Phase 2 per-commit diary is in git history at the
+pre-compaction commit's parent; the "Where Phase 2 stands" table below is the
+current state.
The goal is **feature parity on this architecture**: every shape the old
execution seams supported reaches either a kernel or an explicit, inspectable
@@ -22,9 +26,13 @@ canonicalize table is in `include/ibex/ir/canonicalize.hpp`.
## Why
-`src/runtime/chunked.cpp` (~13k lines) is the physical planner, most streaming
-operator implementations, parallel islands, pipelined stages, and a large set of
-operator-specific eligibility rules — all grown together because
+`src/runtime/chunked.cpp` remains the residual streaming operator
+implementations and a large set of operator-specific construction rules.
+Planning lives in `physical_plan.cpp`, migrated-plan validation and dispatch in
+`physical_executor.cpp`, and generic map/morsel execution in
+`pipeline_executor.cpp`. Aggregate and streaming inner join have moved to
+family-owned translation units, but the remaining responsibilities are still
+grown together because
`build_operator(const ir::Node&)` lowers logical nodes directly into mutable
`Operator::next()` objects. Three costs: (1) a physical choice has no
representation ("stream this join", "materialize this aggregate" are builder
@@ -102,22 +110,26 @@ parser AST → typed logical IR → physical plan → pipeline executable
and profiled — preserves median/quantile/EWMA/predicates/reshape until a
physical implementation exists.
-## Where this stands (2026-08-24 / -25)
+## Where this stands (re-verified 2026-08-29)
**Done:** Phase 1 (physical plan exists, inspectable); Phase 2 (map kernels
ported, fusion is physical, fused node kinds retired as an execution concern);
Phase 3 items 1/3/4 (one executor-owned ordered handoff, islands dissolved into
a pipeline mode, no raw-thread branch concurrency); Phase 4 **construction
-ownership** (every breaker PDS-H reaches is built from the plan — backlog
-116→6 breakers, plan describes 97% of real-work nodes).
-
-**The 97% flatters it:** that measures who *constructs* operators, not how they
-are shaped. The operators are unchanged — a join is still one
-`ChunkedInnerJoinOperator`, not `HashBuild` feeding a separate `HashProbe`
-across a barrier; the aggregate is still one operator, not four phases. So a
-probe can't be a pipeline step (can't fuse with filters/projections above it),
-one build can't feed several probes, and the aggregate's phases can't be
-scheduled or measured separately.
+ownership, parallelism authority, and targeted decomposition** (every breaker
+PDS-H reaches is built from the plan — backlog 116→6 breakers, plan describes
+97% of real-work nodes; Distinct, streaming Join, and streaming Aggregate read
+resolved fan-out policy from the plan; join and aggregate expose explicit
+execution phases).
+
+**What the 97% contains:** the streaming inner join is shaped as explicit
+`HashBuild` and `HashProbe` nodes across a typed runtime-oriented barrier. The
+hash-aggregate fallback now has typed discovery, accumulation, final-ordering,
+and emission plan nodes. A serial coordinator invokes all four: discovery
+publishes a bounded per-chunk transfer that accumulation consumes before the
+source advances, while owned/async one-pass kernels publish an explicit fused
+result. Each node has its own execution-profile row. Semi/anti retains its
+separate streaming operator.
### Next, in order
@@ -132,14 +144,17 @@ scheduled or measured separately.
plan-execution time. Measured: q21 −8.5% from the parallel fill; scheduled
invocation geomean −1.9% over 22 queries (q21 +5.1%, q19 +5.5% reported not
averaged). Kill switches `IBEX_JOIN_BUILD_SERIAL` / `IBEX_JOIN_BUILD_LAZY`.
-2. **Make the probe a step inside a map pipeline — BUILT 2026-08-25, off by
- default** (`f8e84446`, `7cc940b1`). Correct at 1/2/8 cores. **Fires on 1 of
- 22 queries** (q17), so it can't be measured — no performance claim.
+2. **Make the probe a step inside a map pipeline — DONE structurally.** The
+ eligible probe is now admitted into `build_morsel_worker_chain` and installed
+ before the row-local map steps; `build_pipeline_from_input` extracts the
+ probe side rather than materializing join output first. This preserves the
+ intended source → probe → map worker shape, including swapped-mode coverage.
+ `HashProbe` is now also an explicit physical-plan and executor node; this
+ still carries no end-to-end performance claim.
Preconditions met: build is a scheduled phase (`8cb4e936`), build side is
jointly owned so N probes share one (`6df9a966`), probe is its own operator
- (`177b7a93`, `JoinProbeOperator`). What remains: admit it into
- `build_morsel_worker_chain`, which today takes only row-local `MapStep`s — a
- probe is 1:N and the chain builder has no word for that.
+ (`177b7a93`, `JoinProbeOperator`). The physical promotion is complete; the
+ opt-in map-chain fusion remains the measured construction described here.
- **Coverage is the finding.** PDS-H join modes: 28 `Stream`, 12
`Precomputed`, 11 `Swapped`. `Precomputed`/`Swapped` materialize both sides
and emit one table — no probe pipeline to give. `Swapped` (a third of
@@ -153,17 +168,45 @@ scheduled or measured separately.
NOT dominate (14% of join self-time, ≤10.5% of any query's wall) —
retiring the standing "assemble_output dominates" note. So the fusion
argument is **structural, not performance**.
- - **Test gap:** the extraction shipped a deterministic q18 segfault that all
- 1756 ctests passed through — the suite has no deferred-probe join whose
- resolved right falls under `kStreamRightThreshold`. `check_answers.py`
- caught it 21/22. Close before the morsel-chain work.
+ - **Deferred-probe threshold regression — DONE 2026-08-29.** The focused
+ test starts with a 70k-row deferred right, publishes a build-side
+ membership filter that resolves it to one row, then exercises the
+ below-`kStreamRightThreshold` BuildRight path after the 20k-row left was
+ drained. It asserts filter publication, parallel probe activation, and
+ serial/parallel structural equality.
3. **Phase 4 aggregate decomposition** — discovery / per-partition slots / final
ordering / emission as phases. **Determinism blocker cleared 2026-08-27**
- (see below — the divergence no longer reproduces; guard test landed). Slice 1
- (observability: `partition` + `finalize` phases on the plan, `explain
- physical` prints them, `check_agg_plan` aborts on disagreement) LANDED.
- Slices 2–3 move the authority (delete the operator's open-coded floors +
- `min(budget, pool, 64)` caps).
+ (guard test landed). The preflight is complete: all four structural-node
+ policies appear in `explain physical`; aggregate fan-out gates read their
+ node's resolved policy; `AggregateColumnMapping` binds known schemas
+ during planning and lazy/open schemas once at execution; and physical-plan
+ mutation tests prove mapped positions, typed edges, and worker ceilings are
+ consumed or rejected. **Structural plan slice DONE 2026-08-29:** the hash
+ fallback carries typed Discovery → Accumulation → FinalOrdering → Emission
+ nodes and ownership edges; `explain physical` renders the chain and the
+ executor rejects missing, redirected, or mistyped edges. **Serial lifecycle
+ slice DONE 2026-08-29:** final ordering is no longer triggered implicitly by
+ emission; a coordinator drains the input, invokes the deterministic ordering
+ merge, then permits output construction. Discovery and accumulation remain
+ deliberately fused only where owned/async or specialized categorical kernels
+ produce final aggregate state in one pass.
+ **Discovery-transfer/accounting slice DONE 2026-08-29:** ordinary paths pass
+ group IDs, positional aggregate entries, row count, and any seeded-First mask
+ through one chunk-bounded `AggregateDiscoveryTransfer`; fused kernels return
+ an explicit fused marker. The coordinator invokes Discovery and Accumulation
+ separately before advancing the source, and all four structural nodes have
+ independent profile rows. Release A/B over `groupagg,multi,events` (7
+ interleaved repeats, 3 timed iterations, pinned core) found all nine query
+ deltas noise; final total +0.22%, geometric speedup 0.992×.
+ **Structural fan-out authority DONE 2026-08-29:** the coarse `partition` /
+ `finalize` records are removed. Discovery, Accumulation, FinalOrdering, and
+ Emission each carry and supply their own policy; explain renders all four,
+ and profile-backed mutations prove the executor consumes their individual
+ worker ceilings. Data-derived morsel/partition counts and specialization
+ thresholds remain next to the kernels that can observe them. Release A/B
+ against the preceding commit over `groupagg,multi,events` (7 interleaved
+ repeats, 3 timed iterations, pinned core) classified all nine deltas as
+ noise; total +0.53%, geometric speedup 0.998×.
4. **Port `Tail` / `TopK` / `FilterHead` / `FilterTail` — DONE.** Same
single-operator shape as Order/Head: `plan_physical` marks each migrated,
`build_physical_{tail,topk,filter_head_tail}` construct them (moved verbatim
@@ -171,13 +214,29 @@ scheduled or measured separately.
physical` renders `Breaker() serial (single-operator breaker, no
fan-out point)`. TopK stays a serial bounded-heap select by design. No
behaviour change.
-5. **Phase 5 item 1 — split `chunked.cpp` by ownership** — easier the more of
- Phase 4 has landed.
-6. **Sweep process-global plan counters in tests** — one test passed while its
- premise was false (`physical_materialized_calls` is process-wide, other tests
- in the binary bump it). Others may lean the same way.
+5. **Phase 5 item 1 — split `chunked.cpp` by ownership — IN PROGRESS.** Aggregate,
+ streaming inner join, physical-plan dispatch, and the generic pipeline/morsel
+ executor are extracted. The pipeline unit owns worker chains, ordered handoff,
+ two-phase filter, deferred-scan pipeline, asynchronous stage, and source
+ strategy; concrete row-local factories remain callbacks owned by their
+ operator families. No extra call was added to `Operator::next()`. Semi/anti,
+ materializing joins, and residual breaker families remain in their existing
+ owners. **Phase 5 item 3 DONE 2026-08-29** (`5f7afc59`, `94957719`,
+ `d1204b63`; `plans/physical-fallback-adapter-plan.md`): the 15-branch
+ materializing per-kind switch in `build_operator_impl` is gone. Every
+ non-migrated kind resolves through one `build_materialized_fallback` →
+ `interpret_node`; `build_materialized_fallback` still builds the breaker's
+ relational inputs through `build_operator` (handed back via
+ `ExecutionContext::pre_materialized_children`) so a filtered/projected input
+ keeps its fused parallel scan. `explain physical` names the retained subtree.
+ `chunked.cpp` −290 lines net. Next: physical-plan-migrate individual fallback
+ kinds as `physical_fallbacks_for(kind)` ranks them.
+6. **Sweep process-global plan counters in tests — DONE 2026-08-29.** The
+ formerly false-premise test now checks the migrated pipeline counter. All
+ three remaining counter assertions take a local before/after delta, and no
+ test reads `physical_materialized_calls`; no counter redesign is required.
7. **Phase 3 item 5 — per-pipeline scheduling accounting** — small, worth more
- after 1–3.
+ once the join and aggregate phases have independent identities to attribute.
8. **Phase 3 item 2 — DOP/memory budgets** — analysed and **blocked**
(`phase3-dop-budget-analysis.md`): the pool is 65% idle with nothing queued,
so a budget rations a non-scarce resource. Reopen when a multi-producer
@@ -342,8 +401,9 @@ breaker operators (Phase 4).
The **stronger** failure model is now shared (sequence-ordered, allocation-
free `record_fault`, worker liveness through an exit guard — the scan
pipeline's first-writer-wins exception path is gone). Naming followed:
- `ParallelIslandOperator` → `MorselPipelineOperator`, stats keys unchanged
- (tooling reads them). **Zero "island" occurrences in `src/`/`include/`/`tests/`.**
+ The prior island executor was replaced by `MorselPipelineOperator`; stats
+ keys stayed unchanged because tooling reads them. **Zero "island" occurrences
+ in `src/`/`include/`/`tests/`.**
`PipelinedStageOperator` keeps its raw thread + plain `std::deque` FIFO (cap
2, single producer) — deliberately not merged (no sequence ordering to
maintain).
@@ -353,9 +413,10 @@ breaker operators (Phase 4).
4. **Eliminate raw-thread construction from join/builder branches — CLOSED, by
deletion.** Both sites (build overlapped with materialize on a raw thread)
measured worse (q09 +57%, then +47.5% under a since-removed helper-thread
- budget; q10 ~−3% didn't survive widening) and were reverted. `chunked.cpp:10002`
- / `:13066` carry the measurements. Branch concurrency needs a **cost-aware**
- gate, not a thread-count one. The only remaining non-pool thread is
+ budget; q10 ~−3% didn't survive widening) and were reverted. The named join
+ builder branches retain the rationale; git carries their former locations.
+ Branch concurrency needs a **cost-aware** gate, not a thread-count one. The
+ only remaining non-pool thread is
`PipelinedStageOperator`'s (item 1's subject).
5. **Per-pipeline scheduling accounting** — not started, worth more after the
join/aggregate splits give it phases to attribute to.
@@ -363,11 +424,11 @@ breaker operators (Phase 4).
**Concurrency-ownership inventory:** raw threads — `WorkerPool` (sanctioned) +
`PipelinedStageOperator` (long-lived, blocks on ring backpressure,
`StageThreadScope` for the profiler). Bounded handoffs — the two sequence rings
-(now `OrderedChunkRing`) + the stage FIFO + the pool's own. 41 `pool.submit`
-sites (`chunked.cpp` 25) — DOP is seized there; `WorkerPool::submit` calls
-`invariant_violation` from a pool thread and 29 sites check
-`on_worker_pool_thread()` first (what makes nested parallelism a crash not a
-deadlock).
+(now `OrderedChunkRing`) + the stage FIFO + the pool's own. Pool submissions
+remain concentrated in the pipeline executor and breaker families — DOP is
+seized there; `WorkerPool::submit` calls `invariant_violation` from a pool thread
+and callers guard nested submission with `on_worker_pool_thread()` (what makes
+nested parallelism a crash rather than a deadlock).
### Phase 4 — migrate the high-value breakers
@@ -378,21 +439,23 @@ remaining are materializing joins (`nulls equal` / `expect` / non-equi
predicates — porting them ports the semantics, not the construction).
**Construction ownership DONE** (Join streaming `f5610646`, Aggregate `902d6941`,
-Order `ececc75f`, Head/Distinct `49ca33c1`). **Decomposition NOT started** — the
-operators are unchanged; the branches moved into `build_physical_join` /
-`build_physical_aggregate` rather than dissolving into pipeline stages, so the
-exit criterion ("fast paths no longer depend on special builder branches") is
-**not met**. A breaker's parallelism (fan-out decision, worker cap, row floor,
-partition strategy) is still private to `chunked.cpp`, invisible to `explain
-physical`.
+Order `ececc75f`, Head/Distinct `49ca33c1`). **Parallelism authority is also
+DONE**: Distinct, streaming Join, and streaming Aggregate receive resolved
+`BreakerParallelism` from the plan rather than deriving their worker caps and
+fan-out permission privately. **Targeted decomposition is DONE**: streaming
+inner join is an explicit HashBuild → HashProbe execution shape, and aggregate
+is an explicit Discovery → Accumulation → FinalOrdering → Emission lifecycle.
+Both execution families now live outside `chunked.cpp`; the remaining Phase 5
+work is separating the generic physical-plan adapter and morsel executor from
+the residual operator families.
**The decomposition target is specified in
[`src/runtime/PARALLELISM.md`](../src/runtime/PARALLELISM.md), "Target:
parallelism as a plan decision"** — the `BreakerParallelism` descriptor, the
planner-vs-operator split (same one `JoinPlan` already made), the `explain
physical` format, the observability-before-authority slicing, and the sequence
-(Distinct → Order/TopK → Join → Aggregate, the last blocked on the determinism
-reconciliation).
+(Distinct → Order/TopK → Join → Aggregate; the determinism reconciliation is
+complete, so the remaining blocker is structural decomposition).
*Method note (decided the outcome twice):* each port = name the builder's own
predicates + de-duplicate, have the planner **relay** them, have the seam
@@ -405,29 +468,135 @@ at all (a one-valued strategy enum would be ceremony).
1. **Hash join** — construction DONE; **data side DONE** (`5918b5cc`, `8a644381`,
`f6a1a632` — build returns an immutable `JoinHashIndex`; `JoinProbe` consumes
one via `shared_ptr` so writing build state during a probe is a
- compile error); **operator side NEXT** (two scheduled operators — see "Next"
- item 2). NOT blocked on a cost model (corrected 2026-08-25).
-2. **Hash aggregate** — construction DONE; phase decomposition NOT started,
- blocked-first on the determinism divergence above.
+ compile error); **map-pipeline probe fusion DONE**; **explicit physical
+ `HashBuild`/`HashProbe` DONE 2026-08-29**. The build produces a move-only
+ `HashProbeInput` whose variant fixes Stream / Swapped / Precomputed
+ orientation; the physical probe consumes it, and the temporary coordinator
+ is discarded at the barrier. Semi/anti deliberately retains its separate
+ streaming operator. **Column binding follow-up DONE 2026-08-29:**
+ `JoinColumnMapping` resolves mapped left/right keys to positions together
+ with the authoritative output plan; known closed schemas bind in the
+ physical planner, while lazy or layout-dynamic inputs bind once at the
+ concrete barrier; probe kernels no longer look columns up by textual key per
+ chunk. The mapping records both input layouts and re-resolves the complete
+ key/output mapping when predicate pushdown or projection pruning narrows a
+ concrete child. Matching-layout mutations are rejected rather than hidden by
+ rebinding. This fixes the q14/q19 crash where valid key positions masked a
+ stale `JoinOutputColumn::source_index`. **Extraction DONE 2026-08-29:** hash
+ build state, orientation, probe kernels/operators, gather assembly, and
+ deferred-probe resolution live in `join_chunked.cpp`. `chunked.cpp` retains
+ the generic morsel adapter through `JoinProbeFactory`; semi/anti and
+ materializing fallbacks did not move. NOT blocked on a cost model.
+2. **Hash aggregate** — construction, positional column binding, fan-out
+ authority, physical-plan mutation coverage, and the four-node structural
+ hash-fallback chain DONE. Serial orchestration, the bounded
+ discovery→accumulation transfer, fused-result marker, final-ordering/emission
+ split, and per-node accounting are also DONE. The former determinism blocker
+ is resolved. `StreamingSorted` is the historical name for an adaptive
+ strategy: sorted group-at-a-time when possible, hash fallback otherwise
+ (including ordinary generated tables). Each structural node now owns its
+ fan-out policy. **Extraction DONE 2026-08-29:** the whole adaptive
+ sorted/hash family now lives in `aggregate_chunked.cpp` behind one private
+ factory. No hot loop or state boundary was split across translation units.
+ `AggregateColumnMapping` also records the layout it was resolved against: a
+ lazy child that consumes a predicate-only column may rebind once at its
+ concrete boundary, then every phase remains positional. This fixes the q01
+ regression introduced when a logical closed schema was mistaken for a fixed
+ physical layout.
3. **Distinct + ordered** — construction DONE; `Tail`/`TopK`/`FilterHead`/
`FilterTail` ported too (see "Next" item 4). The whole Head/Tail/TopK/Filter*
family and Distinct/Order now leave the per-kind switch.
4. Delete the `chunked.cpp` classes only after the physical path handles every
- supported shape and the fallback is mutation-tested. Blocked on 1–2.
+ supported shape and the fallback is mutation-tested. The aggregate classes
+ and streaming inner-join classes are now deleted from `chunked.cpp`.
### Phase 5 — retire the monolith, simplify IR
-1. Split by ownership: `physical_planner`, `pipeline_executor`, `kernels/`, one
- file/family per breaker.
+1. Split by ownership: `physical_plan`, `physical_executor`,
+ `pipeline_executor`, `kernels/`, one file/family per breaker. **IN
+ PROGRESS:** aggregate, streaming inner join, physical-plan execution, and the
+ generic pipeline/morsel executor are complete; planning lives in
+ `physical_plan.cpp`. Residual breaker-family extraction remains.
2. Move logical fusion/selection out of `ir::NodeKind` — **DONE** for
`FilterProject` / `FilterUpdateProject`: both legacy types and their
compatibility lowering are deleted.
3. Remove obsolete `build_operator` recursion; migrate `interpret_node` to an
- explicit physical fallback adapter.
+ explicit physical fallback adapter. **DONE 2026-08-29** (`5f7afc59`,
+ `94957719`, `d1204b63`; `plans/physical-fallback-adapter-plan.md`). The
+ materializing per-kind switch is one `build_materialized_fallback` seam;
+ `build_operator` no longer recurses through a fallback subtree (only through
+ the pre-built relational inputs). `interpret_node` keeps its own recursion as
+ the fallback interpreter.
4. Make planner / executor / kernel tests independently runnable.
Exit: `chunked.cpp` no longer exists as a monolithic execution/planning unit.
+### Follow-up sequence
+
+1. **Deferred-probe threshold regression — DONE 2026-08-29.** Force a deferred
+ right side to resolve below `kStreamRightThreshold`, then assert serial and
+ parallel byte-identity and that the deferred build/probe path is reached.
+2. **Explicit physical `HashBuild` and `HashProbe` nodes — DONE 2026-08-29.**
+ The data-only plan has distinct typed nodes connected by a
+ `RuntimeOrientedBuildOutput` edge; both retain the candidate inputs, and
+ `build_physical_join` consumes their policies. At execution the build moves
+ a Stream / Swapped / Precomputed `HashProbeInput` across that edge and the
+ probe owns all downstream work; the enclosing coordinator is discarded.
+ Edge mutations are rejected by the same validator execution calls, and
+ materializing plus semi/anti shapes carry no inner-join edge. The immediate
+ name-resolution audit is also complete: the edge carries one
+ `JoinColumnMapping` (positional keys + output provenance), resolved at plan
+ time when possible and once at execution otherwise.
+3. Split aggregate execution at its existing ownership boundaries — discovery /
+ partition accumulation / final ordering / emission — first with serial
+ orchestration and plan-shape/accounting tests, then admit fan-out one phase
+ at a time with byte-identity checks. The typed plan shape and edge-mutation
+ tests, serial orchestration, bounded discovery transfer, fused marker, and
+ independent profile accounting are complete. Each structural node now owns
+ and supplies its fan-out policy, with byte-identity and profile-backed
+ worker-ceiling mutations. This step, including extraction of the resulting
+ aggregate family, is complete.
+4. Add per-phase scheduling accounting only after steps 2–3 provide stable
+ pipeline identities. Keep DOP/memory budgeting blocked unless those changes
+ produce measured queue contention or a multi-producer consumer.
+5. Move the resulting planner, executor, kernels, join, and aggregate families
+ out of `chunked.cpp`; replace residual recursion with the explicit physical
+ fallback adapter, preserving mutation-tested `MaterializedCall` coverage.
+ **Aggregate DONE 2026-08-29.** Correctness: focused physical tests plus all
+ 1,815 non-slow tests pass; SF4 q01 is byte-identical at one and eight cores.
+ Performance: the generated `groupagg,multi,events` A/B classified all nine
+ deltas as noise (total −2.33%); a fixed-but-unextracted SF4 baseline versus
+ the extracted target classified q01/q13/q22 all the same (geomean +0.3%,
+ byte-identical). Widened q01 alone was also a wash (+1.4%, p=0.478).
+ **Streaming inner join DONE 2026-08-29.** Correctness: all 1,815 non-slow
+ tests, focused physical/join/deferred-probe tests, and SF4 q05/q09/q14/q19/q21
+ pass. The strict GCC runtime build passes. Performance: all four generated
+ join cases classified as noise (total −0.34%); ten join-heavy SF4 queries
+ were byte-identical and a wash (geomean −0.3%, every query under the 2%
+ practical floor). The opt-in `IBEX_PROBE_MORSELS=1` POC retains a
+ pre-existing SF4 q09 stall in both baseline and extracted trees; it remains
+ disabled and is a separate correctness follow-up.
+ **Physical-plan executor DONE 2026-08-29.** `physical_executor.cpp` owns root
+ validation, migrated-kind dispatch, path accounting, and
+ `build_operator_from_physical_plan`; concrete operator factories stay with
+ their implementations. Correctness: focused physical tests (29 cases, 4.34M
+ assertions), all 1,815 non-slow tests, and the strict GCC runtime build pass.
+ Performance: the broad
+ generated A/B total was −2.00%; a replica-controlled 18-query
+ core/groupagg/join run classified every delta as noise (total +0.77%), and a
+ 31-repeat join confirmation classified all four joins as noise (total
+ +0.15%).
+ **Pipeline/morsel executor DONE 2026-08-29.** Worker-private map chains, the
+ bounded ordered ring, two-phase filter, deferred-scan pipeline, asynchronous
+ stage, and source-strategy orchestration now live in
+ `pipeline_executor.cpp`; `chunked.cpp` supplies concrete map factories and
+ residual breaker construction through a narrow internal interface.
+ Correctness: focused pipeline/physical tests (24 cases, 4.34M assertions),
+ all 1,815 non-slow tests, the strict GCC runtime build, and debug/Release
+ Parquet + LightGBM plugin builds pass. Performance: a replica-controlled
+ `pipeline,filter,join` A/B (15 interleaved repeats) classified all 13 deltas
+ as noise; total +0.24%.
+
## Acceptance gates (every phase, before the next starts)
Full parser/IR suite; byte-identical serial + parallel across chunk grains
diff --git a/plans/physical-fallback-adapter-plan.md b/plans/physical-fallback-adapter-plan.md
new file mode 100644
index 00000000..e342491a
--- /dev/null
+++ b/plans/physical-fallback-adapter-plan.md
@@ -0,0 +1,141 @@
+# Explicit physical fallback adapter
+
+Phase 5 item 3 of [`kernel-pipeline-execution-plan.md`](kernel-pipeline-execution-plan.md):
+*"Remove obsolete `build_operator` recursion; migrate `interpret_node` to an
+explicit physical fallback adapter."* Also tracked there as "Next" item 5 and
+follow-up-sequence item 5 ("replace residual recursion with the explicit physical
+fallback adapter, preserving mutation-tested `MaterializedCall` coverage").
+
+## Problem
+
+`build_operator_impl` (`src/runtime/chunked.cpp` ~4923-5405) still carries a
+~430-line per-`NodeKind` `if`-chain below the physical-plan seam. The seam itself
+is already clean:
+
+```cpp
+const physical::Plan plan = physical::plan_physical(node, registry, externs);
+if (plan.migrated) return build_migrated_physical_operator(...); // join, agg, order,
+ // distinct, head/tail/topk,
+ // map-pipeline
+physical::note_materialized_call(plan.reason, node.kind());
+// ... ~430 lines of per-kind if-chain ...
+// tail:
+auto table = interpret_node(node, registry, scalars, externs, exec, model_out);
+return make_table_source(std::move(table.value()));
+```
+
+The `if`-chain splits three ways:
+
+| Bucket | Kinds | Branch behaviour |
+|---|---|---|
+| **A. Materializing breakers** | `Columns, Melt, Dcast, Cov, Corr, Transpose, Matmul, Resample, Window, AsTimeframe, Model, Construct, Stream, Program`, materializing `Join`, non-row-local `Update` | recurse `build_operator(child)` → `materialize_operator` → table fn → `make_table_source` |
+| **B. Genuinely-streaming sources** | `Scan`+`stream_scans` (deferred lazy scan), `ExternCall` | build a real chunked / pipelined source operator |
+| **C. Near-dead map duplicates** | `Filter, Project, Rename` | build row-local map operators, recursing into `build_operator` |
+
+Confirmed: `interpret_node` has a branch for every bucket-A kind and recurses into
+itself for children, so bucket A hand-rolls child recursion that `interpret_node`
+already does. The function tail *is* the adapter — bucket A only needs to reach
+it.
+
+`explain physical` prints `MaterializedCall()` with no node identity,
+contradicting the header contract (`physical_plan.hpp:36`: *"`MaterializedCall`
+naming the logical subtree retained by the fallback"*).
+
+## Steps
+
+### Step 1 — Name the `MaterializedCall` node (observability first) — DONE `5f7afc59`
+
+`explain_physical` (`physical_plan.cpp` ~760) emits the root `NodeKind` in the
+`MaterializedCall(...)` line, e.g. `MaterializedCall(Melt: root is not a
+row-local map)`. `plan.root` is always set (`plan_physical` line 509). Reuse
+`node_kind_name_impl`. Update the three affected assertions in
+`tests/test_physical_plan.cpp` (lines ~562, ~760, and the join-materializing
+block ~1138). No execution change. Landed first, on its own commit.
+
+### Step 2 — Collapse bucket A into the tail adapter — DONE `94957719` (pending A/B)
+
+Deleted the 15 per-kind `if` blocks (`Columns, Melt, Dcast, Cov, Corr,
+Transpose`, materializing `Join`, `Matmul, Update, Resample, Window, AsTimeframe,
+Model, Construct/Stream, Program`) so they fall through to the single
+`interpret_node` + `make_table_source` tail. Each was a hand-synced copy of an
+`interpret_node` branch that produces the same table via the same table fn with
+the same args and error strings (verified, including the grouped-update
+rank/tuple dispatch and the `window` + `select_only` projection). `Model`
+threads `model_out`; `Program` runs the preamble — both handled by
+`interpret_node`. `build_binary_materializing_operator` had no other caller and
+was removed.
+
+Initial wholesale collapse (route everything to `interpret_node`) confirmed a
+regression: `join_filter_rank` +14.7% (`regression` verdict, 15 repeats). Root
+cause — a `Filter` feeding a bucket-A breaker (here the grouped-rank `update`
+between a join and its output filter) lost the fused parallel scan `build_operator`
+gave it; `interpret_node` re-evaluated it whole-table and serial.
+
+### Step 3 — `build_materialized_fallback` keeps input construction on the physical path
+
+`build_operator_impl`'s tail now calls `build_materialized_fallback`, which:
+
+1. Resolves the node's **relational inputs** via `fallback_relational_inputs` — a
+ `switch` allowlist. Most kinds: the direct children. `Window`: the *grandchild*
+ (its direct child is an `update` clause `interpret_node` must own). `Stream`,
+ `Construct`, `Program`, and anything unlisted: none (their children are
+ template / expression nodes, not relational subtrees).
+2. Builds and drains each input via `materialize_row_local` (= `build_operator` +
+ `materialize_operator`) — the fused parallel path.
+3. Runs `interpret_node` over the node with those inputs handed back through a
+ new `ExecutionContext::pre_materialized_children` list (node ptr → table).
+ `interpret_node` checks it at entry and returns the pre-built table instead
+ of recursing; only direct inputs are listed, so the fallback node itself and
+ everything deeper are interpreted normally.
+
+`interpret_node` still owns every per-kind semantic; this only moves where the
+inputs are built. Recoverable end state is unchanged — a kind can still be
+lifted to a fully migrated breaker-over-pipeline, driven by
+`physical_fallbacks_for(kind)`.
+
+Validation: all 1,815 non-slow tests; strict GCC; Debug + Release builds. A
+first attempt without the `Window` grandchild / `Stream` exclusions failed 51
+tests (evaluating an `update` clause or a `__stream_input__` transform
+standalone) — the allowlist is load-bearing. Release A/B over
+`join,reshape,window,stats,transform,multi` vs pre-step-2 pending.
+
+### Step 3 — Separate bucket B from the fallback bucket
+
+`Scan`(deferred) and `ExternCall` are not materialized calls — the plan
+deliberately leaves bare-source streaming to the executor (`EmptyChain`). Today
+`note_materialized_call` fires for them, polluting the migration-backlog counter.
+Either teach `plan_physical` to mark bare streaming sources `migrated`, or
+exclude `EmptyChain` bare-source from the count. Keep the operators unchanged.
+
+### Step 4 — Prove bucket C dead
+
+A `Filter`/`Project`/`Rename` root over any classifiable source or breaker
+becomes a migrated `MapPipeline`. Confirm the residual branches are reachable
+only on `MalformedMapNode` (where they return the same structural error), then
+replace with `invariant_violation` or delete.
+
+### Step 5 — Confirm recursion is gone
+
+After step 2 the only `build_operator` recursion left is inside migrated builders
+(join / aggregate / order children) and bucket B sources. That is the
+"remove obsolete `build_operator` recursion" goal met.
+
+### Step 6 — Validate
+
+Focused physical/interpreter tests; `explain physical` snapshot updates; full
+non-slow suite (1,815); strict GCC runtime build; Debug + Release Parquet /
+LightGBM plugin builds; `check-object-equivalence.sh` on the touched breakers;
+Release interleaved A/B.
+
+## Expected outcome
+
+`chunked.cpp` drops ~400 lines. The fallback becomes one tail adapter plus a
+`MaterializedCall` node that names its subtree in `explain physical`.
+Mutation-tested `MaterializedCall` coverage in `tests/test_physical_plan.cpp` is
+preserved and extended with the node name.
+
+## Not in scope
+
+Porting the 6 materializing joins (intended `MaterializedCall`); a join
+build-side cost model; deferred-probe selectivity. Same exclusions as the parent
+plan.
diff --git a/plans/pipelined-execution-plan.md b/plans/pipelined-execution-plan.md
deleted file mode 100644
index 0bdd8600..00000000
--- a/plans/pipelined-execution-plan.md
+++ /dev/null
@@ -1,641 +0,0 @@
-# Pipelined Execution
-
-Status: **partially implemented.** Multi-chunk correctness, streamed scans,
-concurrent scan-unit decoding, the first source-to-map pipeline, and a bounded
-join-probe handoff have landed. The general scheduler across arbitrary pipeline
-breakers remains open.
-Written 2026-08-14 from the thread sweep below; status updated 2026-08-16.
-Read `plans/runtime-multithreading-plan.md` and
-the removed `plans/chunked-execution-plan.md` first (git history); this proposes the thing
-stop short of, and it supersedes neither.
-
-## The measurement that motivates it
-
-PDS-H SF-2, one box, `taskset -c 0-(N-1)`, the same core budget handed to both
-engines, four archived runs in `benchmarking/tpch/results/runs/`:
-
-| cores | Ibex total | speedup | efficiency | Polars total | speedup | efficiency | Polars/Ibex |
-|---|---|---|---|---|---|---|---|
-| 1 | 3779 ms | 1.00× | 100% | 8164 ms | 1.00× | 100% | **2.16** |
-| 2 | 2982 | 1.28× | 64% | 4141 | 1.87× | 93% | 1.39 |
-| 4 | 2575 | 1.49× | 37% | 2671 | 2.73× | 68% | 1.04 |
-| 8 | 2309 | 1.66× | 21% | 2000 | 3.44× | 43% | 0.87 |
-
-**Ibex is 2.16× faster than Polars on one core and 1.15× slower on eight.**
-
-The shape matters more than the endpoints. Ibex's *first* doubling returns
-1.28×, against Polars' 1.87×. It is short from the start and short by roughly
-the same proportion at every step (marginal +0.28 / +0.21 / +0.17 against
-+0.87 / +0.86 / +0.71). That rules out contention and memory bandwidth, which
-would show a near-ideal first doubling degrading later.
-
-Amdahl fits the whole curve from one number. Solving the 2-core point gives a
-parallel fraction of **≈44%**; feeding that forward predicts 1.62× at 8 cores
-against 1.66× observed. Ibex behaves like a program that is **56% serial**, at
-every thread count.
-
-Per query the implied parallel fraction has a **median of 41%, and 14 of 22
-queries are below 50%**. The top of the range is fine — q06 at 93% scales
-3.29×, beating Polars' own 2.09× on that query, and q01 at 82% reaches 2.71×.
-Those are the queries that are essentially one row-local pass. Everything with
-a join or a group-by is in the bottom half.
-
-This also explains why five separate operator-level threading attempts on
-2026-08-14 each did what they claimed to their operator and moved the query by
-0–3%: on a query that is 80% serial, doubling one operator's parallel phase is
-worth almost nothing. Four were reverted for being unmeasurable and one for
-regressing.
-
-## Diagnosis: materialize-then-fan-out
-
-The executor is a pull-based chunk pipeline already — `Operator::next()` returns
-a `Chunk`, and `Chunk` even carries `sequence` and `row_offset` so an ordered
-merger can reassemble morsels produced out of order. The substrate anticipated
-this work.
-
-What is missing is that **nothing ever produces more than one chunk, and no two
-chunks are ever in flight at once.** Three places enforce that:
-
-1. **Every production source is drained to one table.** The REPL's batch path
- resolves a reader through `lazy_table_func`, calls `LazyTable::project`, and
- wraps the result in `TableSourceOperator`, which emits exactly one chunk.
- Verified: `chunks=1` on every operator of every PDS-H query.
-2. **A chunked source is materialized even when one exists.**
- `ChunkedParquetSourceOperator` streams 65536-row Arrow batches
- (`plans/bigger-than-ram-plan.md` Phase 4), and `src/runtime/chunked.cpp`
- around the `chunked_table_func` branch immediately calls
- `materialize_operator()` on it.
-3. **Parallel islands materialize before fanning out**, by a documented
- load-bearing invariant: the island's input subtree is executed to a `Table`
- on the calling thread, and every morsel source below takes that finished
- table by reference. That is what makes a `LazyTable` safe inside an island.
- Islands also only cover row-local chains, which is why only 5 of 22 queries
- form one at all.
-
-So each operator runs to completion, on one thread, before the next begins.
-Whatever parallelism exists is strictly *intra*-operator with a serial merge
-between, and the serial phases of every operator add up with nothing to overlap
-them. That is the 56%.
-
-Polars reaches 3.44× because morsels flow through the whole pipeline
-concurrently: one morsel joining while another decodes and a third aggregates.
-
-A consequence worth stating because it is a correctness risk, not just a
-performance one: **the cross-chunk paths in the operators have never run.**
-`KeyPartition::stored`, `partitioned_active_`, `cat_dictionary_id_`, the
-distinct operator's `packed_part_count_` pinning, the pair path's dense-array
-rebuild — all of it is written for multi-chunk input and all of it is dead
-today. One latent bug in that machinery was already found and fixed on
-2026-08-14 (`4ba4b75`) purely by reading. There will be others.
-
-## The constraint that shapes the design
-
-The single-chunk path is not an accident or laziness — it is where the biggest
-wins of the last months live, and they are worth more in absolute terms than
-the threading gap:
-
-- projection pushdown (`plans/parquet-*`)
-- dynamic filter pushdown, geomean ≈ −12%
-- decode fusion incl. late materialization, geomean −14%
-- null-free stats fast paths, geomean −27%
-- the fused key-filter scan, q17 −28%
-
-Every one of those works by giving `LazyTable` the *whole* query's demand —
-columns, conjuncts, join keys — and letting it decode once, minimally. A naive
-"stream row groups through the pipeline instead" throws all of it away, and the
-arithmetic is not close: −12/−14/−27% against a threading ceiling that, even if
-perfectly achieved, is worth ~1.6× on 8 cores.
-
-**So the design constraint is: pipelining must be built on top of the pushdown
-machinery, not instead of it.** The natural shape is that a scan still plans its
-decode globally (all pushdowns intact) but *yields* the result in row-group or
-batch units rather than as one table.
-
-## Phases
-
-Deliberately ordered so each is separately measurable and the risky one is last.
-
-### Phase 0 — make multi-chunk real, and prove it correct — **DONE (`f9db6a0`)**
-
-No performance goal. Get more than one chunk flowing and find out what breaks.
-
-- A test-only or env-gated switch that makes a source emit N chunks for a
- materialized table (`PartitionedTableSource` already does exactly this, and is
- currently used only by islands and tests).
-- Run the full PDS-H answer check and the 1574-test suite with it on, at several
- grains including pathological ones (1 row, 1 chunk, prime-sized).
-- Extend the parity comparator (`plans/done/serial-parity-comparator-plan.md`) to
- assert chunked-vs-single-chunk equality structurally, not by diffing stdout.
-
-Exit criterion: every query is byte-identical at every grain. Expect real bugs
-here — this machinery has never executed.
-
-### Phase 1 — a streaming scan that keeps its pushdowns — **DONE, on by default**
-
-- Give `LazyTable` a way to yield its planned decode in units (row group, or
- Arrow batch) instead of one table, with projection, conjuncts, dynamic key
- filters and late materialization already applied.
-- Route the batch path to it, keeping `TableSourceOperator` for anything that
- cannot.
-- Measure with pushdowns on. The bar is *no regression*: this phase buys memory
- and cache locality, not parallelism.
-
-Trap: `LazyTable::cache_` is not thread-safe and the deferred-probe path
-explicitly declines when a key column is already cached. Streaming must not
-quietly disable the fused scans — check by diffing plan shapes (profiler
-`op="..."` counts), which is how a silent decline was caught on 2026-08-14.
-
-**What landed.** `SourceUnit` is a source-global row range a reader can decode
-alone; `LazySourceReader::decode_units()` reports them (Parquet: one per row
-group) and `decode` / `key_filter_scan` / `string_filter_scan` all take one.
-`LazyTable::project_where_unit` is `project_where` restricted to a unit, with
-every pushdown applied to that unit rather than declined — the fused scans
-included, because both already plan over the whole file and answer in
-source-global indices, so restricting them is a filter on their group list.
-`DeferredScanSourceOperator` drives it. **Streaming is the default**;
-`IBEX_STREAM_SCAN=0` opts out, and answers both ways for the same reason
-`IBEX_PARALLEL` does — a switch that could only turn it on leaves no way to
-turn it off.
-
-Two things had to be got right and are worth remembering. Selections stay
-source-global at every boundary, which is what lets the unit path reuse the
-whole-source filtering code instead of growing a second index space. And a unit
-decode never touches `cache_`: a unit holds a *fragment* of a column, and a
-fragment in the cache is indistinguishable from a whole one to every later
-reader — including the fused scans, which decline when their key column is
-cached.
-
-**The bar was not met, and the way it was missed is the useful result.**
-PDS-H SF-1, interleaved, min-of-5, answers identical and plan shapes identical
-(no pushdown silently declined):
-
-| | geomean vs materialized |
-|---|---|
-| 1 core | **0.943** |
-| 8 cores, `taskset -c 0-7` | **1.084** |
-
-Streaming is **5.7% faster on one core and 8.4% slower on eight**. Since the
-same binary does the same work in both, the regression is not extra work — it
-is *lost parallelism*, and the single-core number says the phase delivered
-exactly the cache-locality win it promised. q01's decode confirms it directly:
-pool work drops 234ms → 125ms while wall rises 123ms → 208ms, and occupancy
-falls 0.44 → 0.14.
-
-The cause is the diagnosis above, one level down. A unit is ~1M rows, which is
-plenty to parallelize (`parallel_min_rows` is 65536, so no gate is being
-missed) — but six units run one after another with a serial phase between each,
-so the pool sees six short bursts instead of one long one and nothing overlaps
-them. Materialize-then-fan-out, at unit granularity.
-
-**This was Phase 2's case, made quantitatively.** Concurrent scan units are
-not a refinement of Phase 1; they turn the 0.943 into a better 8-core number.
-They do *not*, by themselves, turn the operator chain into a pipeline. The
-remaining scheduler work is what must move the parallel fraction.
-
-Per-query, the 8-core split is wide (q12 −29%, q08 −9%; q01 +69%, q20 +42%,
-q19 +36%). q21 is the one query that loses single-threaded too (+13.6%) and is
-the place to start if Phase 2 lands and something still regresses.
-
-### Phase 2 — concurrent chunks — **scan slice and join slices landed; scheduler open**
-
-The desired end state is multiple chunks in flight through the operator chain.
-What has landed so far is narrower: multiple scan units decode concurrently,
-then their window is harvested and consumed in order. This is useful
-intra-source parallelism, but it is not pipeline concurrency.
-
-**What landed: concurrent units inside the scan.** The scan decodes a WINDOW of
-units on worker threads instead of one after another. Ordering is untouched —
-workers claim units from a shared cursor and write only their own slot; after
-the caller harvests the completed window, chunks are served in unit order with
-`sequence` / `row_offset` assigned on the calling thread.
-
-This distinction is load-bearing: the source waits for the entire window before
-it returns its first chunk, and it cannot start the next window until the
-current one has been consumed. Decode can use the pool, but downstream joins,
-aggregates, and row-local operators do not overlap with it. Treat any result
-from this slice as scan parallelism, not evidence of end-to-end scaling.
-
-Decoding a unit on a worker is safe because `LazyTable::acquire_reader` hands
-each concurrent acquisition its own reader product (that is what the reader pool
-was built for), `project_where_unit` never writes `cache_`, and every inner
-parallel path checks `on_worker_pool_thread()` and runs serial inside a task.
-The middle one is now load-bearing rather than merely tidy: routing any part of
-the unit path back through `project()`, which does cache, turns it into a race.
-
-PDS-H SF-1, 8 cores, interleaved, geomean against the materialized path:
-
-| | geomean |
-|---|---|
-| Phase 1 (units, serial) | 1.084 |
-| Phase 2 (units, concurrent) | **0.922** |
-
-Streaming is now **7.8% faster** than materializing, where Phase 1 was 8.4%
-slower. q12 −44%, q06 −31%, q04 −23%, q19 −21%, q14 −19%, q01 −17%, q15 −16%.
-
-**Two fixes mattered more than the concurrency itself**, and both were found by
-following the profile rather than by reasoning about the design:
-
-* *Per-row dictionary interning.* The chunk-to-chunk categorical remap interned
- **per row** instead of per dictionary entry. On q01 that was 114ms of a 160ms
- scan — the entire regression — and it was invisible from the query: TPC-H's
- `l_returnflag`/`l_linestatus` are plain `string` in the Arrow schema and only
- become Categorical because the writer dictionary-encoded them. Interning each
- dictionary entry once and gathering codes took q01 from +90% to −18%.
-* *A concat that only existed because chunks did.* The semi/anti join's swapped
- path called `MaterializeOperator` on its left, which was free when the left
- was one chunk and a full copy once it was six. It buffers the chunks as a list
- now — it needs the left twice, but never glued. q21's semi join went 113ms ->
- 87ms, and the query from +40% to +10%.
-
-The second is the shape to expect more of: **operators that were written against
-a one-chunk world hide a concat.** They are correct either way, so only a
-profile finds them.
-
-**q21, run down.** The residual was in a profiled statement after all — an
-earlier single-sample reading said otherwise and was wrong. Statement 1 (+16%)
-carried the whole of it, and inside that statement the cost was not any operator
-but the **sink**: `MaterializeOperator` spent 33ms appending where the
-single-chunk path spent 0, because one chunk is *moved* into the result table
-and six must be concatenated. Appending was a `push_back` per row even for a
-flat numeric column; `append_column_values` now bulk-copies, which took
-statement 1 from +16.4% to +8.9%. What is left is the memmove itself —
-`li_F` is 2.9M rows of four POD columns, so ~58MB of copying that the
-single-chunk path never does.
-
-That cost is inherent while a `let` binding is one contiguous `Table`: streaming
-a large intermediate into a binding must glue it back together. It is the same
-lesson as `plans/runtime-multithreading-plan.md`'s "the MERGE CONCAT is the real
-island cost", and it says the wins track *output* size — which is exactly the
-observed split, since every query that gains reduces its rows sharply (q06,
-q12) and every one that loses binds a large intermediate.
-
-**q20, run down — and the general lesson.** Its high-cardinality group-by cost
-50ms -> 79ms under streaming, and the cause was not the aggregate's merge but
-its **gate**: `pool_tasks` went 32 -> 0, i.e. partitioned group discovery
-declined outright. The gate asks "are there enough rows here to be worth
-partitioning", and it asked it of the CURRENT CALL. q20's aggregate sees 909k
-rows over 543k groups — comfortably qualifying — but as six chunks that is
-~151k per call, under the 262144 threshold, so it declined on every one.
-
-**Chunking divides every per-chunk row gate by the number of chunks.** That is
-the systemic consequence of this phase and it will keep biting; q20 is simply
-where it bit first. The engine's other row gates use `parallel_min_rows`
-(65536) and still clear it at these sizes, which is why only this one showed.
-
-Fixed by counting the rows the *operator* has been offered rather than the rows
-in this call. The threshold itself is unchanged — lowering it is a measured
-dead end, because the break-even is set by group cardinality, not row count
-(`plans/` history: q13 +9.3%). Starting part-way through a stream then means
-groups already exist in the serial index, which the partitioned path neither
-reads nor writes, so they are seeded into the partitions keeping their existing
-ids. Only the packed path cannot do this — its key is built from a row and is
-not invertible — so it declines to start late, which is the previous behaviour.
-
-q20 +23% -> +10%, q21 +9% -> +5%, suite geomean unchanged at 0.921. What is
-left on q20 is that its first chunk is still discovered serially and its groups
-then seeded; closing that needs the aggregate to defer its first chunk until
-the decision is made.
-
-**q22 +7%** and the rest of **q21** are the concat above. The scan itself is
-now faster than materializing on every query measured.
-
-### Turning it on by default
-
-Flipping it found a **data-loss bug** that every measurement up to that point
-had missed: the window loop asked `batch_.has_value()` to decide whether a
-window was in flight, and a ONE-UNIT window is decoded inline and never
-submits. So a serial query dropped every unit after the first, and a parallel
-one dropped any trailing single-unit window. The PDS-H suite never saw it —
-lineitem's 6 units fit one window of 8 exactly — and only the e2e's
-`IBEX_PARALLEL=0` leg, which asserts a row total, caught it. The e2e streaming
-check now runs serially on purpose rather than by luck.
-
-The lesson: **the shapes that break streaming are window remainders and serial
-execution**, and neither is exercised by the benchmark suite.
-
-### Memory: not the free win it looks like
-
-Streaming was expected to bound peak memory. It does — but only below the
-window. Peak RSS scales with the window, which is the thread budget:
-
-| PDS-H SF-1 | materialized | streamed w1 | streamed w8 |
-|---|---|---|---|
-| q04 | 193M | 127M | 268M |
-| q19 | 137M | 57M | 170M |
-
-So `IBEX_THREADS` now bounds peak decode memory as well as parallelism, and at
-the default window several queries use 20-47% MORE than materializing. Much of
-that excess is not live data: it is glibc growing a free list per worker arena,
-since decoding moved onto the pool. `MALLOC_ARENA_MAX=1` takes q13's +33% down
-to +2% and q04's +45% to +20%.
-
-A lookahead window — dispatching window k+1 before serving window k — was built
-and **reverted as a measured dead end**: +52% peak RSS on a 25-row-group scan
-(161MB -> 244MB) for zero wall-clock change, because the consumer is a blocking
-operator that eats chunks faster than they decode. There is no consumer work to
-overlap with until the rest of the pipeline runs concurrently.
-
-### What the sweep says about the rest of the phase — read this first
-
-Repeating the thread sweep was this plan's own gate ("the number that must move
-is the implied parallel fraction, not the wall time"). Run at SF-2 on the same
-harness, materialized vs streamed:
-
-| | 1c | 2c | 4c | 8c | speedup | implied parallel fraction |
-|---|---|---|---|---|---|---|
-| materialized | 4990 ms | 4153 | 3568 | 3236 | 1.54× | 34% |
-| streamed | 4456 ms | 3967 | 3365 | 3033 | 1.47× | **22%** |
-
-Streaming is faster at every core count (−11% at 1c, −6% at 8c) and the
-parallel fraction went **DOWN**. It did not make more of the program parallel;
-it made the serial part cheaper. **The premise at the top of this document —
-that pipelining is what raises the parallel fraction — is not what Phase 2's
-first slice delivered**, and nothing about the rest of the phase should be
-justified by it without new evidence.
-
-(Caveat: this harness times whole processes, so startup and plugin load are
-counted as serial and deflate the fraction against the `run_bench.sh` numbers
-in the table at the top. The materialized-vs-streamed comparison is same-harness
-and sound; the absolute 34%/22% are not comparable to the 44% above.)
-
-### Where the serial time actually is
-
-Calling-thread ms summed over real operator nodes across the suite, 8 cores,
-with the worker help each drew (`pool_work / self`). The first column is the
-reading that opened this section; the second is the same measurement after the
-joins were worked on.
-
-| operator | self ms (before) | self ms (now) | pool ms (now) | worker help |
-|---|---|---|---|---|
-| scan | 479 | 631 | 2902 | 4.6× |
-| aggregate | 473 | 552 | 945 | 1.7× |
-| **join inner** | **422** | **435** | 80 | **0.2×** |
-| join semi | 326 | **183** | 285 | 1.6× |
-| join anti | 33 | 36 | 14 | 0.4× |
-| update | 57 | 70 | 145 | 2.1× |
-| distinct | 40 | 48 | 191 | 3.9× |
-
-**Joins were 42% of calling-thread operator time and drew essentially no worker
-help.** That reading was right, and it is what the rest of this phase should be
-steered by. The scan, which this phase spent its effort on, is now the
-best-parallelized operator in the engine.
-
-**How to read `self ms` — settled by reading the profiler, after getting it
-wrong twice.** `ExecutionProfileScope` pushes a frame per scope and adds its
-whole elapsed time to its parent's `child_ns`, which the parent subtracts. That
-applies to `ProfilePhase::Source` exactly as it does to a nested operator, so
-**a source stage's time is EXCLUDED from the enclosing operator's self, not
-added to it**. A mid-session claim that `join inner`'s 422ms was mostly its
-deferred probe's decode was wrong on that mechanism; the empirical check is q03,
-whose source stages cost 147ms of calling-thread time while its two `join inner`
-rows report 10.6ms and 7.6ms between them.
-
-The practical consequence is the opposite of what that wrong reading implied:
-the joins' self ms is real join work, and the ranking above stands.
-
-1. **Semi/anti join** — **DONE.** Its `filter_chunk` was 266ms across the suite
- with the pool idle, split 4:1 predicate to gather. Only the predicate was
- threaded: each one probes a set that stops changing before the first left
- chunk arrives, so ranges need no coordination and each builds its own index
- list. The gather is deliberately left serial — threading a gather here is the
- same memory-bound dead end already recorded over `ChunkedDistinctOperator`.
- q21 −29%, suite −4.6%; the row above went 326 self / 0 pool to 183 / 285.
-2. **Inner join** — still 435ms at 0.2×, but **it is not one lever**. Timed
- directly, the operator's own work splits into `probe_chunk` 91ms,
- `assemble_output` 48ms, `build_index` 42ms, `emit_swapped` 7ms, and a
- remainder that is phase A's IN-MEMORY passes (`filter_selection`,
- `apply_membership_filter`, the two-phase hit loop) — those are not source
- stages, so they land in the join's self.
-
- So the shelved `git stash` "parallel inner-join probe" addresses
- `probe_chunk` + `assemble_output` ≈ **139ms**, or 3-4% of suite wall if
- perfectly parallelized. Worth doing, but it is not what makes the join row
- large, and it should not be sold as such.
-
- Also measured: the full two-phase branch's `left_copy` — a deep copy of the
- build table — **never runs in PDS-H**. `build.rows() > sel.selected.size()`
- sends every query down the `RightMaterialized` branch instead. Do not spend
- effort there without a query that reaches it.
-
-### What came out of following that ranking
-
-Two changes landed that the ranking did not predict, both found by timing phase
-A of the deferred probe rather than the operator that owns it:
-
-* **Phase A now fuses membership past a static filter.** Its fused key scan
- required `conjuncts.empty()`, a condition copied from `project_where`, so a
- probe scan carrying a filter decoded the whole key column AND the predicate
- column and then walked every row twice, serially. Membership now runs inside
- the decoder and the conjuncts are evaluated through its selection. q03 −37%,
- q07 −24% at 8 cores. Gated on fixed-width conjunct columns: a sparse read of a
- variable-width column is not proportionally cheaper (q10 +9.3% when it was
- not gated).
-* **The key scan's abandon rule is asked 16× sooner.** It needs 262k rows to
- fire but was only asked at ~1M-row group boundaries, so a doomed scan decoded
- a whole group — and in parallel every other in-flight group finished
- alongside it. Group 0 now asks it per 64k batch, which is sound because group
- 0's rows ARE the file-order prefix. That removed the q05/q10 regressions the
- fusion change introduced, and those regressions had SCALED WITH CORE COUNT
- because the overshoot was bounded by one worker wave.
-
-Cumulative for both, PDS-H SF-2, interleaved, min of 6: suite −3.3% at 1 core,
-−3.4% at 4, −4.0% at 8, with q03 −37% and q07 −24% at 8.
-
-**Still open where phase A is concerned:** membership-first wins only when
-membership is more selective than the conjuncts, and nothing at this layer
-estimates that. q10 probes `orders` against an unfiltered customer table and its
-scan still abandons — now cheaply. The estimate would come from the footer-stats
-cost model behind `join_reorder`.
-
-The scheduler remains the design for running the whole chain concurrently. The
-original thread sweep does argue for it: the current scan slice makes the
-single-core path faster but leaves Ibex at 1.66x on eight cores, versus Polars'
-3.44x. The next phase must be judged by whether it raises the implied parallel
-fraction and reaches at least Polars parity at the fixed core budget, not by a
-small operator-local win.
-
-**What landed: the first source pipeline.** A decomposable lazy scan now
-publishes units through a bounded ordered ring as soon as they complete; it no
-longer waits for a whole decode window. A downstream breaker therefore consumes
-earlier chunks while later units are still decoding. When a maximal row-local
-chain starts directly at the scan, `build_operator` builds one private chain per
-worker and the same task immediately runs its decoded unit through those maps
-before publishing it. There is no whole table or whole-window wait at either
-boundary. Empty-schema carriers, categorical dictionary unification, sequence
-order, cancellation, and backpressure are preserved at the ordered publication
-point.
-
-One pool thread is reserved for downstream parallel work when a long source can
-fill the ring and park every producer. Without that reservation a blocking
-operator can submit a batch while every pool thread is on the backpressure
-condition: the caller waits for the batch, the scan workers wait for the caller
-to drain, and neither can move. A source of at most `3 * workers` units cannot
-reach that state — the ring holds `2 * workers` and the workers have already
-claimed at most one unit each — so those common short Parquet scans retain the
-full decode budget. A one-thread pool keeps the old serial-window source.
-
-This is deliberately the first scheduler slice, not the completed scheduler.
-It covers source → optional row-local maps → breaker, including the direct
-scan-to-aggregate shape and the map chain that previously hit
-`build_parallel_island`'s materialize-before-fan-out boundary.
-
-**What landed next: a bounded join-probe handoff.** `PipelinedStageOperator`
-keeps the pull `Operator::next()` API on both sides of a breaker while a
-dedicated producer thread drives the join below it and publishes at most two
-ordered chunks. Its caller can consequently aggregate or map one probe output
-while the join pulls and probes the next. It is intentionally not a
-`WorkerPool` task: a streamed scan already owns tasks from that fixed pool, and
-using the same pool for a long-lived stage recreates the producer/backpressure
-deadlock that the scan worker reservation avoids. Cancellation wakes both queue
-waits before joining; producer errors cross the queue exactly once.
-
-The builder stages only streamable inner/semi/anti joins whose *left/probe*
-subtree contains a multi-unit deferred scan. One-chunk table joins have no
-overlap to expose and stay on the original pull path; terminal aggregates also
-stay on the caller because a hash aggregate emits only after its input ends.
-`ParallelIslandStats::pipelined_stages` and the deferred four-unit join test
-make both the gate and ordering observable.
-
-This is the breaker-output mechanism Phase 2 needed, but it is not yet a
-general scheduler: aggregate outputs, other semi-blocking probe paths, shared
-stage admission, and retirement of the materialized-island executor remain
-open. The stage is also not a scaling win yet. A same-binary release screening
-run at SF-1 (five timed iterations, not interleaved) measured 4 cores
-1208→1255 ms (+3.9%, query geomean 1.042) and 8 cores 1070→1075 ms (+0.5%,
-geomean 1.018). Treat that as a negative result, not a benchmark claim: the
-general scheduler needs progress-aware admission and a repeated interleaved
-sweep before this stage can satisfy Phase 2's performance gate.
-
-PDS-H SF-1, pinned cores, whole-script mode, release builds at `8f1e349`
-versus this slice (warmup 1-2, 5-7 timed iterations):
-
-| cores | baseline total | pipeline total | total delta | query geomean |
-|---|---:|---:|---:|---:|
-| 1 | 1976 ms | 1913 ms | -3.2% | 0.963 |
-| 2 | 1583 ms | 1542 ms | -2.5% | 0.963 |
-| 4 | 1355 ms | 1292 ms | -4.6% | 0.935 |
-| 8 | 1141 ms | 1092 ms | -4.3% | 0.964 |
-
-The implied parallel fraction moves only about one percentage point (48% to
-49% from the 1/8 totals), so this is a useful source-overlap win, not yet the
-curve-changing scheduler the phase is aiming for. q01 is consistently about
-14% faster at eight cores; q22 is the consistent outlier at +12-19%. The
-phase's original no-regression bar was explicitly loosened for this slice on
-2026-08-16, so q22 remains follow-up work rather than a reason to keep the
-pipeline disabled.
-
-SF-4 makes both the scaling benefit and a low-core scheduling defect clearer.
-The table below is the mean of two order-balanced sweeps of the same baseline
-and worktree, with one warmup and five timed iterations per query in each
-sweep. Higher scale factors are timing-only because the official qualification
-answers in this repository apply only to SF-1.
-
-| cores | baseline total | pipeline total | total delta | query geomean |
-|---|---:|---:|---:|---:|
-| 1 | 8305 ms | 8419 ms | +1.4% | 1.000 |
-| 2 | 6309 ms | 6742 ms | +6.9% | 1.077 |
-| 4 | 5274 ms | 5242 ms | -0.6% | 0.951 |
-| 8 | 4533 ms | 4366 ms | -3.7% | 0.953 |
-
-The one-core geomean is neutral, as expected because the scan pipeline is
-disabled for a one-thread pool; its total is dominated by noisy long-tail
-queries and is a control rather than evidence of pipeline overhead. At four
-and eight cores the broad query geomean improves about 4.7-4.9%, and the 1/8
-totals move the implied parallel fraction from about 52% to 55%. q18 remains
-the large counterexample (+24% at four cores, +13% at eight); q20 is +10% at
-four cores and q17 is +12% at eight.
-
-Two cores expose a separate policy cliff. Long sources reserve one of the two
-pool threads to avoid producer/breaker deadlock, leaving a single decode
-producer while still paying the pipeline handoff cost. The suite is +6.9%,
-with q06 +47%, q04 +24%, and q12 +24%. A follow-up admission gate that declined
-the one-producer configuration was remeasured against `6ef0c60` at SF-4 and
-made the important late queries materially worse: q19 +28%, q20 +21%, q21
-+20%, and q22 +17% in a target-then-baseline three-iteration sweep. The
-producer still overlaps decode with the caller's serial breaker work, so the
-gate was withdrawn. The next scheduler slice must replace static reservation
-with progress-aware admission/backpressure rather than infer usefulness from
-the producer count. The relaxed no-regression bar permits keeping this first
-slice enabled, but the two-core crossover and q18 are named follow-up gates
-rather than noise to average away.
-
-The rest of this phase must retain the same bounded-queue contract: a source
-may publish completed units as they become ready, row-local stages may consume
-and publish morsels independently, and a blocking stage supplies backpressure
-before the next pipeline. A whole-window `wait()` at the source boundary is
-expressly not sufficient.
-
-Requires deciding, per operator, which of three it is:
-
-| class | operators | behaviour |
-|---|---|---|
-| streaming | filter, project, rename, update (row-local) | chunk in, chunk out; trivially concurrent |
-| blocking | aggregate, sort, distinct, join build side | must see all input before emitting |
-| semi-blocking | join probe, top-k | blocked on build, streaming on probe |
-
-The classic morsel-driven answer is that a *pipeline* runs from a source to the
-next blocking operator, and pipelines are scheduled with the blocking operator
-as a barrier. That is a scheduler, and it replaces `build_operator`'s
-straight-line chain — this is the large part of the work and should not be
-started before Phases 0 and 1 have landed and held.
-
-Ordering is the contract to preserve: `Chunk::sequence` / `row_offset` exist for
-exactly this, and `MaterializeOperator`'s in-order concat is the existing
-ordered merger. Everything Ibex reports in first-occurrence order (group-by
-output, distinct, `head`) depends on it.
-
-### Phase 3 — retire the island special case
-
-If Phase 2 lands, parallel islands become a special case of the general
-scheduler and the materialize-before-fan-out invariant can go. Not before.
-
-## Validation
-
-- **Answers**: all 22 PDS-H answers byte-identical at every phase, at every
- grain. Non-negotiable; this is the gate that catches the dead cross-chunk
- paths waking up.
-- **Suite**: `ctest` (1574) plus `scripts/ibex-e2e.sh` at each phase.
-- **Performance**: `run_bench.sh` archives + `compare_runs.py`, which reports a
- reference engine's drift so a box-condition change cannot be read as a result.
- Repeat the 1/2/4/8 sweep at each phase. The scheduler's acceptance gate is
- **at least Ibex/Polars multi-core parity at the same pinned core budget**;
- the leading diagnostic is the **implied parallel fraction**, not a
- single-core or operator-local wall-time change.
-- **Interleaved A/B with a control query** for anything narrower, per the
- methodology that caught three false positives on 2026-08-14.
-
-## Non-goals
-
-- **Not out-of-core.** `plans/bigger-than-ram-plan.md` owns spilling. Streaming
- helps peak RSS as a side effect; that is not the goal here and must not be
- used to justify a regression on the timed path.
-- **Not a rewrite of the operators.** They are already chunk-shaped. The work is
- in what feeds them and what schedules them.
-- **Not more intra-operator threading.** That is the thing this measurement says
- has run out of road. `plans/runtime-multithreading-plan.md` Phase 4 remains
- open for specific gaps, but it is not the answer to the curve above.
-
-## Open questions to settle before the scheduler slice
-
-1. Where does the scheduler live — inside `build_operator`'s seam (the
- `execution-plan-seam-plan.md` Option B position), or above it?
-2. What is the chunk grain, and is it per-source or global? `IBEX_MORSEL_ROWS`
- exists for islands; a pipeline may want a different answer.
-3. How does a blocking operator's *build* phase get parallelised, or does it
- stay serial? The measurement says `build_index` is only 1.5% of q10, so
- possibly it stays serial forever, which would simplify the scheduler a lot.
-4. Does the REPL's statement-at-a-time model need to change, or can a pipeline
- stay inside one statement? Everything above assumes the latter.
-
-## Honest assessment of size
-
-Phase 0 is days and will surface bugs. Phase 1 is a substantial change to the
-most performance-sensitive code in the tree, with a hard no-regression bar
-against four separate pushdown mechanisms. Phase 2 is a scheduler and is the
-kind of change that touches the contract every operator is written against.
-
-The upside is bounded and known: the curve says a perfect result is ~3.4× on 8
-cores where we get 1.66×, i.e. roughly halving total PDS-H time and turning a
-0.87 ratio into ~1.5 in Ibex's favour. That is the largest single number
-available anywhere in the tree, and it is not reachable in pieces — which is the
-argument for doing it, and equally the argument for scoping it properly first.
diff --git a/plans/runtime-multithreading-plan.md b/plans/runtime-multithreading-plan.md
index 08e8fe90..55da8e76 100644
--- a/plans/runtime-multithreading-plan.md
+++ b/plans/runtime-multithreading-plan.md
@@ -5,7 +5,8 @@ development diary and the completed-phase detail moved to git history at the
pre-compaction commit's parent. `parallelism-overview.md` is the current map of
what actually shipped; this file is the phase roadmap and the two design
sections that are still load-bearing (the LazyTable Synchronization Contract,
-Phase 2 RNG).
+Phase 2 RNG). The separate pipelined-execution plan was removed as superseded;
+its remaining scheduler work is owned by `kernel-pipeline-execution-plan.md`.
> **Nomenclature drift:** this plan predates the config rename. `IBEX_THREADS` →
> **`IBEX_CORES`** (compute budget); `IBEX_PARALLEL` was **removed** — serial is
@@ -16,10 +17,10 @@ Phase 2 RNG).
Multithreading as a query-execution capability, not ad-hoc loops inside kernels.
The `Chunk` operators are the data unit for a morsel-driven executor. A query is
-a sequence of **parallel islands** (maximal runs of row-local, chunk-preserving
-operators) separated by **barriers** (order / join / group-by / distinct /
-window / rank / model). Start with one ordered parallel island for row-local
-work, keep serial implementations behind barriers, expand only after each
+a sequence of **morsel-parallel map pipelines** (maximal runs of row-local,
+chunk-preserving operators) separated by **barriers** (order / join / group-by /
+distinct / window / rank / model). Start with one ordered parallel map pipeline
+for row-local work, keep serial implementations behind barriers, expand only after each
operator family has an explicit correctness contract. DuckDB's model
(partitionable sources, pipeline breakers, worker-local + sparing query-global
state) without a general DAG scheduler.
@@ -87,14 +88,14 @@ documented ownership contract — **not** blanket "make it thread-safe". The
hazard surface is narrower than the object.
**Interim gate (Phase 0) — LIFTED 2026-08-02, worth ~nothing.** The gate made
-any query reading a lazy source island-ineligible. Removed once established that
-no worker can reach a `LazyTable`: `build_parallel_island` materializes its
-input subtree to an owned `Table` on the building thread, and every morsel
-source takes that finished table by `const Table&`. Measured: 2 of 22 PDS-H
-queries changed eligibility, q19 gained an island (no time change), **only 5 of
-22 form an island at all** (q18/q19/q21×2/q22). The gate was never what kept
+any query reading a lazy source map-pipeline-ineligible. Removed once
+established that no worker can reach a `LazyTable`: the morsel pipeline
+materializes its input subtree to an owned `Table` on the building thread, and
+every morsel source takes that finished table by `const Table&`. Measured: 2 of
+22 PDS-H queries changed eligibility, q19 gained a map pipeline (no time
+change), **only 5 of 22 form one at all** (q18/q19/q21×2/q22). The gate was never what kept
PDS-H serial — whole-script mode eagerly projects non-probe scans, and scan
-conjuncts get pushed into the decoder (removing the `Filter` an island builds
+conjuncts get pushed into the decoder (removing the `Filter` a map pipeline builds
from). What's left above the scans is joins/group-by/sort — barriers. **The
PDS-H multithreading gap is Phase 4, not Phase 3b.** A slice that streams a
source's morsels straight into workers reintroduces the hazards and must
@@ -181,13 +182,12 @@ actively detects re-entry** — a fake `ColumnDecodeFn` that sets an atomic
in-flight flag on entry and fails if already set. ThreadSanitizer build for the
concurrent-lazy-scan tests.
-## Phase 1 — First parallel island — LANDED, ON by default
+## Phase 1 — First morsel-parallel map pipeline — LANDED, ON by default
`ExecutionContext::parallel` defaults true. What shipped, in order:
-- **Serial island + worker pool + ordered merger** — `ParallelIslandOperator`
+- **Morsel pipeline + worker pool + ordered merger** — `MorselPipelineOperator`
dispenses morsels off one atomic cursor, per-worker map chain, bounded ring
- merge by `sequence`. `SerialIslandOrderValidator` asserts the two executors
- are byte-identical.
+ merge by `sequence`. Tests assert serial and parallel byte-identity.
- **Row-local `Update` eligibility** — an unguarded, ungrouped, tuple-free
update whose every field is `is_subset_evaluable_expr` (stricter than
`is_row_local_update_expr` — the looser one admits aggregates, which per
@@ -284,8 +284,9 @@ ordered units; units decode concurrently with independent reader products; a
direct source or maximal row-local chain publishes completed units downstream
without waiting for a decode window. First bounded join-probe output handoff
landed. Pushdown / cancellation / backpressure / dictionary unification
-preserved. See `pipelined-execution-plan.md` for SF-1/SF-4 measurements and the
-unresolved two-core admission problem.
+preserved. The historical SF-1/SF-4 measurements are in git; the unresolved
+two-core admission problem and general breaker work are now tracked by
+`kernel-pipeline-execution-plan.md`.
**Still open:** CSV, TSAN coverage, generalized source partitioning (SF-1 has
too few row groups — must partition columns and row ranges, not just row
diff --git a/src/ir/join_output.cpp b/src/ir/join_output.cpp
index d49cc7d1..39d71974 100644
--- a/src/ir/join_output.cpp
+++ b/src/ir/join_output.cpp
@@ -161,4 +161,41 @@ auto plan_join_output(JoinKind kind, const std::vector& keys,
return plan;
}
+auto resolve_join_columns(JoinKind kind, const std::vector& keys,
+ std::span left_names,
+ std::span right_names,
+ const JoinSuffixPolicy& suffix)
+ -> std::expected {
+ auto output = plan_join_output(kind, keys, left_names, right_names, suffix);
+ if (!output.has_value()) {
+ return std::unexpected(std::move(output.error()));
+ }
+
+ JoinColumnMapping mapping;
+ mapping.left_input_names.reserve(left_names.size());
+ for (const std::string_view name : left_names) {
+ mapping.left_input_names.emplace_back(name);
+ }
+ mapping.right_input_names.reserve(right_names.size());
+ for (const std::string_view name : right_names) {
+ mapping.right_input_names.emplace_back(name);
+ }
+ mapping.output = std::move(*output);
+ mapping.keys.reserve(keys.size());
+ for (const JoinKey& key : keys) {
+ const auto left = std::ranges::find(left_names, key.left);
+ if (left == left_names.end()) {
+ return std::unexpected("join key " + quote(key.left) + " not found in left input");
+ }
+ const auto right = std::ranges::find(right_names, key.right);
+ if (right == right_names.end()) {
+ return std::unexpected("join key " + quote(key.right) + " not found in right input");
+ }
+ mapping.keys.push_back(
+ {.left_index = static_cast(std::distance(left_names.begin(), left)),
+ .right_index = static_cast(std::distance(right_names.begin(), right))});
+ }
+ return mapping;
+}
+
} // namespace ibex::ir
diff --git a/src/ir/schema.cpp b/src/ir/schema.cpp
index 74ad73be..2a9fa5fd 100644
--- a/src/ir/schema.cpp
+++ b/src/ir/schema.cpp
@@ -687,19 +687,17 @@ auto check_one_join(const JoinNode& join, const SchemaInfo& left, const SchemaIn
for (const auto& key : join.keys()) {
const auto* left_field = left.find(key.left);
const auto* right_field = right.find(key.right);
- // Only a closed schema proves absence: an open one lists the columns it
- // knows about and admits others.
- if (left_field == nullptr && !left.is_open()) {
+ // Open schemas admit extra physical columns, but those columns are not
+ // addressable until an ascription names them. Every referenced join
+ // key therefore has to appear in the declared portion on both sides.
+ if (left_field == nullptr) {
return "join key '" + key.left +
"' not found on the left side (available: " + format_field_names(left) + ")";
}
- if (right_field == nullptr && !right.is_open()) {
+ if (right_field == nullptr) {
return "join key '" + key.right +
"' not found on the right side (available: " + format_field_names(right) + ")";
}
- if (left_field == nullptr || right_field == nullptr) {
- continue;
- }
if (!left_field->type.has_value() || !right_field->type.has_value()) {
continue; // an untyped column is still known to exist
}
diff --git a/src/repl/CMakeLists.txt b/src/repl/CMakeLists.txt
index 3951c259..2dc30c2f 100644
--- a/src/repl/CMakeLists.txt
+++ b/src/repl/CMakeLists.txt
@@ -21,6 +21,8 @@ target_include_directories(ibex_repl
PUBLIC
$
$
+ PRIVATE
+ ${PROJECT_SOURCE_DIR}/src
)
target_link_libraries(ibex_repl
diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp
index 8145c9b8..25c73eec 100644
--- a/src/repl/repl.cpp
+++ b/src/repl/repl.cpp
@@ -51,6 +51,8 @@
#include
#include
#include
+
+#include "runtime/physical_plan.hpp"
#ifdef _WIN32
#define NOMINMAX
#include
@@ -140,7 +142,7 @@ enum class ReadLineStatus : std::uint8_t { Line, Eof, Interrupted };
constexpr std::string_view kColonCommands[] = {
":q", ":quit", ":exit", ":help", ":tables", ":scalars", ":functions",
":imports", ":schema", ":head", ":peek", ":describe", ":load", ":timing",
- ":time", ":comments", ":doc", ":source", ":run",
+ ":time", ":comments", ":doc", ":source", ":run", ":explain",
};
constexpr std::string_view kCompletionBuiltins[] = {
@@ -2090,6 +2092,8 @@ void print_help() {
" :peek Evaluate and compactly display an expression, with any\n"
" order-sensitive claims it carries (time index, ordering,\n"
" grouping)\n");
+ ibex::formatting::print(
+ " :explain Show the read-only physical-plan capability for one expression\n");
ibex::formatting::print(" :describe Schema + first rows\n");
ibex::formatting::print(
" :doc Show docs/signature for a binding or built-in\n");
@@ -5309,6 +5313,72 @@ auto try_execute_whole_script(const parser::Program& program, runtime::ExternReg
return true;
}
+/// Lower one REPL expression exactly as the evaluator would, then render the
+/// physical planning capability without materializing a source or executing it.
+void print_physical_explain(parser::Expr& expr, const runtime::TableRegistry& tables,
+ const LazyTableRegistry& lazy_tables,
+ const runtime::ScalarRegistry& scalars, const ColumnRegistry& columns,
+ const ModelRegistry& models, const FunctionRegistry& functions,
+ const CompileTimeListRegistry& compile_time_lists,
+ const ExternDeclRegistry& extern_decls,
+ const runtime::ExternRegistry& externs) {
+ parser::LowerContext context;
+ context.compile_time_lists = compile_time_lists;
+ for (const auto& [name, decl] : extern_decls) {
+ if (decl.return_type.kind == parser::Type::Kind::DataFrame ||
+ decl.return_type.kind == parser::Type::Kind::TimeFrame) {
+ context.table_externs.insert(name);
+ context.table_extern_decls.insert_or_assign(name, &decl);
+ }
+ if (!decl.params.empty() && decl.params[0].type.kind == parser::Type::Kind::DataFrame) {
+ context.sink_externs.insert(name);
+ }
+ }
+ for (const auto& entry : scalars) {
+ context.lexical_names.insert(entry.first);
+ }
+ for (const auto& entry : columns) {
+ context.lexical_names.insert(entry.first);
+ }
+ for (const auto& entry : models) {
+ context.lexical_names.insert(entry.first);
+ }
+ for (const auto& entry : functions) {
+ context.lexical_names.insert(entry.first);
+ context.functions.insert_or_assign(entry.first, &entry.second);
+ }
+ for (const auto& entry : compile_time_lists) {
+ context.lexical_names.insert(entry.first);
+ }
+ for (const auto& entry : tables) {
+ context.lexical_names.insert(entry.first);
+ context.source_schemas.insert_or_assign(entry.first, table_schema_info(entry.second));
+ }
+ for (const auto& entry : lazy_tables) {
+ context.lexical_names.insert(entry.first);
+ context.source_schemas.insert_or_assign(entry.first,
+ table_schema_info(entry.second->schema()));
+ }
+
+ auto lowered = parser::lower_expr(expr, context);
+ if (!lowered.has_value()) {
+ ibex::formatting::print("error: {}\n", lowered.error().message);
+ return;
+ }
+ lowered.value() =
+ ir::push_filters_into_joins(std::move(lowered.value()), context.source_schemas);
+ lowered.value() = ir::push_semi_joins_down(std::move(lowered.value()), context.source_schemas);
+ lowered.value() =
+ ir::reduce_inner_joins_to_semi(std::move(lowered.value()), context.source_schemas);
+ const ir::OptimizationContext optimization_context;
+ lowered.value() = ir::optimize_plan(std::move(lowered.value()), optimization_context);
+
+ const auto plan = runtime::physical::plan_physical(*lowered.value(), tables, &externs,
+ context.source_schemas);
+ ibex::formatting::print("Physical plan (capability; runtime fan-out may differ):\n{}",
+ runtime::physical::explain_physical(plan));
+}
+
} // namespace
auto normalize_input(std::string_view input) -> std::string {
@@ -5693,6 +5763,27 @@ void run(const ReplConfig& config, runtime::ExternRegistry& registry) {
print_table(table.value(), count);
continue;
}
+ if (starts_with_command(line_view, ":explain")) {
+ auto source = trim(line_view.substr(std::string_view(":explain").size()));
+ if (source.empty()) {
+ ibex::formatting::print("usage: :explain \n");
+ continue;
+ }
+ auto parsed = parser::parse(normalize_input(source));
+ if (!parsed.has_value()) {
+ ibex::formatting::print("error: {}\n", parsed.error().format());
+ continue;
+ }
+ if (parsed->statements.size() != 1 ||
+ !std::holds_alternative(parsed->statements.front())) {
+ ibex::formatting::print("error: :explain expects a single expression\n");
+ continue;
+ }
+ auto& expr = std::get(parsed->statements.front()).expr;
+ print_physical_explain(*expr, tables, lazy_tables, scalars, columns, models, functions,
+ compile_time_lists, extern_decls, registry);
+ continue;
+ }
// Accept the obvious typo `:peak` as an alias for `:peek`.
const bool is_peek = line_view.starts_with(":peek") &&
(line_view.size() == 5 || line_view[5] == ' ' || line_view[5] == '\t');
@@ -5839,7 +5930,8 @@ void run(const ReplConfig& config, runtime::ExternRegistry& registry) {
ibex::formatting::print("error: unknown REPL command '{}'\n", cmd);
ibex::formatting::print(
"known: :help, :tables, :scalars, :functions, :imports, :schema, :head, "
- ":peek, :describe, :doc, :source, :load, :timing, :time, :comments, :quit\n");
+ ":peek, :explain, :describe, :doc, :source, :load, :timing, :time, :comments, "
+ ":quit\n");
continue;
}
diff --git a/src/runtime/CMakeLists.txt b/src/runtime/CMakeLists.txt
index 91512d88..6c96155a 100644
--- a/src/runtime/CMakeLists.txt
+++ b/src/runtime/CMakeLists.txt
@@ -3,8 +3,10 @@
add_library(ibex_runtime STATIC
aggregate.cpp
+ aggregate_chunked.cpp
chunk_conversion.cpp
chunked.cpp
+ join_chunked.cpp
env.cpp
execution_profile.cpp
expr.cpp
@@ -17,8 +19,10 @@ add_library(ibex_runtime STATIC
lazy_table.cpp
model.cpp
ops.cpp
+ physical_executor.cpp
physical_plan.cpp
pipeline.cpp
+ pipeline_executor.cpp
reshape.cpp
rng.cpp
runtime_internal.cpp
diff --git a/src/runtime/PARALLELISM.md b/src/runtime/PARALLELISM.md
index 3a953493..1e7302c7 100644
--- a/src/runtime/PARALLELISM.md
+++ b/src/runtime/PARALLELISM.md
@@ -107,7 +107,7 @@ project / rename / row-local update / their fused forms) run as **one
independent task per morsel**, with an **order-preserving merge** that makes the
output byte-identical to the serial chain regardless of completion order.
-`MorselPipelineOperator` (`chunked.cpp`) is the executor. Key rules:
+`MorselPipelineOperator` (`pipeline_executor.cpp`) is the executor. Key rules:
1. **Materialize the input subtree first, on the calling thread.** A
deferred/lazy source decodes exactly once, serially, before any worker
@@ -154,8 +154,9 @@ scatter** for anything variable-width.
## Who owns which decision
-The honest answer is **it depends on the operator category**, and only one
-category has a single clean owner today.
+The answer depends on the operator category. Map chains and the promoted
+Distinct, Join, and Aggregate policies have a clean plan/executor split;
+several remaining breaker internals do not.
### The stable parts
@@ -171,52 +172,48 @@ category has a single clean owner today.
|---|---|---|---|
| **Map chains** (Filter/Project/Rename/row-local Update, fused) | **The physical planner owns it end to end.** `plan.mode` (`Serial`/`MorselParallel`), `parallel_begin`/`parallel_end` (which steps run over morsels), and per-step `MapStep` (capability + kernel factory + column signature). | **Yes, fully.** | Yes. |
| **Join** | The plan owns the **structure** — `JoinPlan` carries build side + runtime-resolved orientation (`49188c71`) — and **both** fan-out phases: `build_partitions` reads `par_.build`, `probe_parallel_workers` reads `par_.probe` (slices 4–5). What stays in the operator is the kill switch / nesting / per-chunk floor. Output assembly is inside `ChunkedInnerJoinOperator`. | **Yes** (structure + both phases, both authoritative). | Structure + both phases. |
-| **Aggregate** | The plan owns `AggregatePlan` (which construction path). **Discovery, per-partition slots, the owned-aggregate hot table, and the finalize merge are inside `ChunkedAggregateOperator`.** | Partial. | Which path, not the phases. |
-| **Distinct / Order / TopK / Head / Tail** | **Nothing in the plan.** `execution_capability(Distinct)` returns `ParallelBarrier`, but that value is **never read to make a decision** — it names what a future executor *could* do. `build_physical_distinct` just constructs `ChunkedDistinctOperator`, which owns the entire decision internally: the `can_fan_out()` / `on_worker_pool_thread()` guard, a private `kMinRows` (hardcoded, and twice — accumulate and finalize), the partition count from `compute_budget()`, the two-pass "one worker per partition scans the whole chunk" model. | **No.** | **No.** |
-| **Layer C fan-out inside any operator** (group discovery, sort gather, decode, semi/anti predicate) | Always the operator's, each with its own private row threshold and its own `min(budget, pool, cap)` worker count. | No. | No. |
-
-**So: there is no single owner of "distinct parallelism" — and the same is true
-of every barrier operator's parallelism.** For map chains the physical plan is
-that owner; for breakers it owns construction (and for join/aggregate,
-structure), and the parallel-execution decisions live inside the operator,
-unrepresented, un-inspectable, and tunable only by editing the operator. Closing
-that gap — decomposing the breakers into planned phases — is
-`kernel-pipeline-execution-plan.md` Phase 4, which is why that plan distinguishes
-"construction ownership done" (backlog 116→6) from "decomposition not started".
-
-### The one rule that already holds everywhere
-
-**The plan says whether parallel execution is *permitted*; the operator says
-whether it is *desirable* here** (`exec.can_fan_out()`, row/cell floors, morsel
-count, `on_worker_pool_thread()`). `plan.mode == MorselParallel` is a
-capability, and a serial execution of that plan (`can_fan_out()` false) must
-still be correct — a q19 crash under `IBEX_CORES=1` came from an executor that
-checked only `plan.mode`. For breakers, both halves currently live in the
-operator.
+| **Aggregate** | The plan owns the adaptive strategy, positional `AggregateColumnMapping`, and four typed hash-fallback nodes (discovery → accumulation → final ordering → emission). Each node carries and authoritatively supplies its own fan-out policy. A serial coordinator invokes every node: discovery transfers one bounded chunk of group IDs/column bindings to accumulation, or marks a one-pass kernel explicitly fused; final ordering and emission are separate. | **Yes, structural and authoritative.** | Strategy + typed chain + all four node policies. |
+| **Distinct** | The plan owns the `dedup` policy (floor, ceiling, packed-key strategy, optional estimate); the builder resolves it and the operator reads it. Nesting, the first concrete chunk's row count, and the derived partition count remain runtime decisions. | **Yes, authoritative.** | Yes. |
+| **Order** | The plan describes one `sort` phase. The actual radix-sort/gather fan-out reads shared `ExecutionContext` knobs in `sort.cpp`, so this phase is descriptive rather than authoritative. | **Yes, descriptive.** | Yes. |
+| **TopK / Head / Tail / FilterHead / FilterTail** | Plan-built serial breaker operators. They have no current fan-out point; TopK deliberately uses a bounded streaming heap rather than a full sort. | **Yes; no parallel policy.** | Serial-by-design reason. |
+| **Remaining Layer C fan-out inside operators** (sort gather, decode, semi/anti predicate, and data-dependent aggregate specialization gates) | The operator owns decisions not yet promoted. Migrated Distinct, Join, and Aggregate paths instead read their resolved plan policy and retain only runtime/data-dependent admission checks. | Mixed. | Only promoted phases. |
+
+**There is not yet one owner for every breaker's parallelism.** The physical
+plan owns map chains and the promoted Distinct, Join, and Aggregate policies;
+other breaker internals remain unrepresented and tunable only at their use
+sites. Aggregate's former coarse `partition` / `finalize` records are gone;
+its remaining coordinator is deliberately serial while the work inside each
+typed node may fan out under that node's policy.
+
+### The split enforced by migrated parallel paths
+
+**The plan says whether parallel execution is *permitted*; the operator handles
+facts available only at runtime** (actual rows/cells, morsel count,
+`on_worker_pool_thread()`, and data-dependent specialization gates).
+`plan.mode == MorselParallel` is a capability, and a serial execution of that
+plan (`can_fan_out()` false) must still be correct — a q19 crash under
+`IBEX_CORES=1` came from an executor that checked only `plan.mode`. Breakers not
+yet promoted still combine these halves at their use sites.
## Worked example: `t[distinct { g, v }]`
1. **Logical IR** — a `Distinct` node over a `Scan`. The optimizer decides
column demand (`g`, `v`), nothing about execution.
-2. **`plan_physical`** — the root is `Distinct`, not a map chain, so
- `plan.migrated == false`, `reason == NotMapChain`. `plan.mode` is irrelevant
- (it's for map chains). No `DistinctPlan` field exists. `explain physical`
- prints `MaterializedCall(Distinct)` — or, since `49ca33c1`, records it as a
- plan-built breaker — and says nothing about how it will run.
-3. **`build_physical_distinct`** — constructs `ChunkedDistinctOperator(child,
- exec)`. Passes the `ExecutionContext` in; makes no parallelism decision.
-4. **`ChunkedDistinctOperator`, first chunk** — decides *everything*: if
- `!exec.can_fan_out() || on_worker_pool_thread() || rows < 32768` it stays
- serial and pins `dedup_part_count_ = 1` for all later chunks; otherwise it
- derives `part_count` from `compute_budget()`, hash-partitions by packed key,
- and runs one worker per partition (each scanning the whole chunk, skipping
- rows not in its partition — the proven Pass-2 model). Determinism device:
- workers record a keep-flag at a row, never a position, so the output is
- rebuilt by scanning flags in row order.
-
-The only externally visible knob is `IBEX_CORES` (via `can_fan_out()` and
-`compute_budget()`). The `32768` floor and the partition strategy are editable
-only in `chunked.cpp`.
+2. **`plan_physical`** — records a migrated `Breaker(Distinct)` with one
+ `dedup` phase: packed-key strategy, 32768-row floor, worker ceiling, and any
+ available row estimate. `explain physical` renders that unresolved policy.
+3. **`build_physical_distinct`** — resolves the policy against the
+ `ExecutionContext` and worker pool, then passes it to
+ `ChunkedDistinctOperator`.
+4. **`ChunkedDistinctOperator`, first chunk** — reads the resolved permission
+ and cap, applies the facts only it knows (nesting and actual rows), and pins
+ a derived partition count for later chunks. Each worker scans the chunk for
+ its partition. Workers record keep-flags by row, so output is rebuilt in
+ input order rather than completion order.
+
+The externally visible compute knob is `IBEX_CORES`; the 32768-row floor and
+packed-key strategy are named once by `distinct_dedup_parallelism` and carried
+by the physical plan.
## The determinism contract
@@ -237,11 +234,10 @@ The devices:
The only legitimate exceptions: PDS-H q01/q09/q15 differ by ≤1 ulp from parallel
float reduction order (itself thread-count-independent), enumerated in
-`beat-polars-plan.md` §5. Anything else that differs is a bug. **Known standing
-violation:** the two-Int64-key owned aggregate (`try_owned_pair`) and the serial
-path re-associate differently and disagree bit-for-bit at ≥65536 rows, with no
-test covering it — `kernel-pipeline-execution-plan.md` "The determinism
-constraint is already broken".
+`beat-polars-plan.md` §5. Anything else that differs is a bug. The former
+two-Int64-key owned-aggregate divergence no longer reproduces; the
+"two-key grouped aggregate is deterministic across thread counts" regression
+test now guards the serial and parallel paths.
## Configuration surface
@@ -265,14 +261,14 @@ disagree.
## Where the model is still muddy
-**The structural one:** breaker parallelism has no owner above the operator (see
-"Who owns which decision"). A breaker's fan-out decision, its partition count,
-and its worker cap are private to `chunked.cpp`, invisible to `explain
-physical`, and un-A/B-able except through `IBEX_CORES`. Every other item below is
-a symptom of that — the private thresholds and worker caps exist because there
-is no plan-level place to put them. Fixing the altitude (decomposing breakers
-into planned phases, `kernel-pipeline` Phase 4) is what makes the rest
-tractable; fixing the symptoms first just moves nine constants into one header.
+**The structural owner now exists for migrated breakers.** Distinct, streaming
+Join, and streaming Aggregate take their fan-out policies from explicit
+physical nodes and expose those decisions through `explain physical`.
+Aggregate's four phases, streaming inner join, migrated-plan dispatch, and the
+generic map/morsel executor now live outside `chunked.cpp`. Remaining
+operator-private decisions belong to breaker families that have not completed
+that migration; do not generalize their local thresholds into a second policy
+system.
**The symptoms** (`plans/parallelism-overview.md` Part 2 is the live
catalogue): type-exclusion rules with no shared "is this type parallel-capable
@@ -349,9 +345,9 @@ struct BreakerParallelism {
RowEstimate estimate{};
};
-// One breaker = one or more named phases, each with its own fan-out point.
-// Distinct/Order/TopK have one; a decomposed Aggregate has three
-// (discovery / accumulate / finalize); a Join has two (hash-build / probe).
+// Untyped breakers retain named phases. Join and Aggregate instead carry the
+// same descriptor directly on their typed HashBuild/HashProbe and
+// Discovery/Accumulation/FinalOrdering/Emission nodes.
struct BreakerPhase {
std::string_view name;
BreakerParallelism parallelism;
@@ -440,8 +436,9 @@ Every slice:
once.
- **Not removing the runtime checks.** `on_worker_pool_thread()` and the
first-chunk floor check are the operator's, permanently.
-- **Not the `chunked.cpp` split.** That is Phase 5, and it is deliberately
- *after* this — the plan says decomposition makes the split easier.
+- **Not ownership of the remaining `chunked.cpp` split.** This contract enabled
+ Phase 5; Aggregate and streaming inner join have now moved, while
+ planner/executor extraction is tracked by the kernel-pipeline plan.
- **Not a row-count estimator project.** The estimate is opportunistic (footer
stats, exact child counts). `partition_count = 0 / derive` is the honest
default and preserves today's behavior exactly.
@@ -495,10 +492,8 @@ Every slice:
onto the plan, byte-identical throughout. The `probe_parallel_workers`
`on_worker_pool_thread()` veto was measured to fire 0/52 on PDS-H, so folding
it changed nothing.
-4. **Aggregate** — `AggregatePlan` gains `partition` + `finalize` phases (the
- two fan-out points `ChunkedAggregateOperator` has today; discovery and
- accumulate are one `pool.submit`, so they are one phase until that region is
- actually decomposed in Phase 5). **Determinism blocker cleared (2026-08-27):**
+4. **Aggregate** — four typed structural nodes now own the hash fallback's
+ scheduling policy. **Determinism blocker cleared (2026-08-27):**
the `try_owned` vs serial re-association divergence recorded below does not
reproduce on the current tree — the serial probe path, the owned path, and a
strict-row-order reference all agree bit-for-bit at every thread count
@@ -508,30 +503,22 @@ Every slice:
`d5928ee2`) reconciled it. Removing `try_owned`'s schedule gate outright was
also tried and reverted — correctness stayed byte-identical but 1-core q20/q18
regressed +25%/+40%. The guard test now exists: `tests/test_interpreter.cpp`
- "two-key grouped aggregate is deterministic across thread counts". Slice 1
- (observability) LANDED: `aggregate_{partition,finalize}_parallelism`,
- `plan_physical` fills the phases, `explain physical` prints them,
- `ChunkedAggregateOperator::check_agg_plan` aborts on planner/operator
- disagreement — byte-identical, full suite + q01/q10/q13/q18/q20/q21 at 1c/8c.
- Slice 2 (partition authority) LANDED: `try_owned` and `try_discover_
- partitioned` read `par_.partition.{decline,worker_cap}` for fan-out
- permission and the worker cap; the open-coded `!can_fan_out()` +
- `min(budget, pool, 64)` are gone from both. The floors stay in the operator
- — the radix `kDefaultPartitionMinRows` beside its constant, `try_owned`'s
- lower `kPairOwnedMinRows` as the operator-resolved "owned specialization
- worth it" gate (like the join's build orientation). Byte-identical vs base
- on q01/q10/q13/q18/q20/q21, full suite.
-
- Slice 3 (finalize authority + the async-hot partition gate slice 2 missed)
- LANDED: `finalize_owned`'s co-ranking merge, the ordered-run merge, the
- first-occurrence seed pass, and the async-hot cold build read
- `par_.finalize.{decline,worker_cap}` for their ceiling and permission; the
- data-derived cap terms (`part_count`, `total/4096`, `run_count/8192`) and the
- three strategy floors (`1U<<17`, `1U<<16`, `parallel_min_rows`) stay in the
- operator. `try_async_hot_int_sum` (the q18 path — a fourth `partition`
- strategy slice 2 did not touch) reads `par_.partition` too now. New
- `ParallelPipelineStats` counters `parallel_aggregate_partitions` /
- `parallel_aggregate_finalizes` so a silently-stopped gate is a red test.
- Byte-identical vs the slice-1 base on q01/q10/q13/q18/q20/q21, full suite
- 1814/1814. **The Aggregate step of "parallelism as a plan decision" is
- complete.**
+ "two-key grouped aggregate is deterministic across thread counts". The
+ observability and authority migration removed the open-coded
+ `!can_fan_out()` / `min(budget, pool, 64)` decisions while retaining
+ data-derived caps (`part_count`, `total/4096`, `run_count/8192`) and
+ strategy-specific admission floors next to their kernels. Existing
+ `ParallelPipelineStats` partition/finalize counters remain compatibility
+ telemetry; structural execution-profile rows are the per-node proof. The
+ executor mutation seam verifies mapped positions, typed edges, and worker
+ ceilings are consumed rather than reconstructed locally.
+
+ Slice 4 (structural-node authority) LANDED 2026-08-29: the coarse records
+ are removed. Discovery owns radix, owned, and async-hot group discovery;
+ Accumulation owns deterministic private-state morsels (including fused
+ categorical/global kernels) and slot initialization; FinalOrdering owns
+ ordered merges and first-occurrence seeding; Emission owns independent
+ output-column tasks. `explain physical` renders
+ all four policies on their typed nodes. Execution-profile mutation tests
+ prove a one-worker node ceiling suppresses that node's pool work without
+ reconstructing defaults, while preserving byte-identical output.
diff --git a/src/runtime/aggregate_chunked.cpp b/src/runtime/aggregate_chunked.cpp
new file mode 100644
index 00000000..79a75ff8
--- /dev/null
+++ b/src/runtime/aggregate_chunked.cpp
@@ -0,0 +1,5349 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright (C) 2026 Bob Jansen
+
+// Adaptive sorted/hash aggregate execution. The complete aggregate family is
+// kept in this translation unit so extraction from the pipeline builder does
+// not split hot templates or state across compilation boundaries.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "physical_plan.hpp"
+
+#if defined(__AVX2__) || defined(__BMI2__)
+#include
+#endif
+
+#include "aggregate_chunked_internal.hpp"
+#include "chunk_conversion_internal.hpp"
+#include "execution_profile_internal.hpp"
+#include "interpreter_internal.hpp"
+#include "packed_key_encoder_internal.hpp"
+#include "runtime_internal.hpp"
+
+namespace ibex::runtime {
+
+namespace {
+
+// Whether a streamed aggregate slot has enough observations to be non-null.
+// Mirrors the materializing aggregate's `agg_result_is_valid`.
+auto chunked_agg_valid(ir::AggFunc func, const AggSlotCore& slot) -> bool {
+ switch (func) {
+ case ir::AggFunc::Mean:
+ return slot.count > 0;
+ case ir::AggFunc::Sum:
+ case ir::AggFunc::Min:
+ case ir::AggFunc::Max:
+ case ir::AggFunc::First:
+ case ir::AggFunc::Last:
+ return slot.present();
+ case ir::AggFunc::Stddev:
+ return slot.count >= 2;
+ case ir::AggFunc::Skew:
+ return slot.count >= 3;
+ case ir::AggFunc::Kurtosis:
+ return slot.count >= 4;
+ default: // Count
+ return true;
+ }
+}
+
+// Whether a streamed aggregate carries a validity bitmap at all (Count never
+// produces nulls; the value-bearing aggs may).
+auto chunked_agg_tracks_validity(ir::AggFunc func) -> bool {
+ switch (func) {
+ case ir::AggFunc::Sum:
+ case ir::AggFunc::Mean:
+ case ir::AggFunc::Min:
+ case ir::AggFunc::Max:
+ case ir::AggFunc::First:
+ case ir::AggFunc::Last:
+ case ir::AggFunc::Stddev:
+ case ir::AggFunc::Skew:
+ case ir::AggFunc::Kurtosis:
+ return true;
+ default:
+ return false;
+ }
+}
+
+/// Streaming hash aggregate. Maintains a `robin_hood` group index and
+/// per-group `AggState` across chunks: each incoming chunk updates the
+/// state per row, the chunk is released, and the final result is
+/// emitted as a single output chunk on EOF.
+///
+/// Eligibility is gated at `build_operator` time to the common subset
+/// that streams cleanly: `Count`, `Sum`, `Min`, `Max`, `Mean` on
+/// numeric (int/double) inputs. Nullable agg inputs are handled — null
+/// rows skip the update, and an all-null group emits a null result.
+/// Nullable group-by columns are not supported yet; they fall back to
+/// `aggregate_table` via `interpret_node`. Complex aggs (Median, etc.)
+/// and string aggs also fall back.
+///
+/// The first chunk's group-by column types are snapshotted (including
+/// the Categorical dictionary pointer when applicable) and reused when
+/// building output; the chunked csv source shares dictionaries across
+/// chunks, matching MaterializeOperator's existing assumption.
+/// A growable array of trivially-copyable slots that grows through `realloc`.
+///
+/// `std::vector` cannot use `realloc`: it must allocate, copy, and free, and on
+/// this array that copy IS the cost. A group-by discovers its groups a chunk at
+/// a time, so the slot array is resized once per chunk and never shrinks; by
+/// the last chunk the copies dominate. Measured on q18 (3M groups over 6
+/// chunks): `size_group_arrays` cost 79ms, and pre-reserving the final size --
+/// which a real query cannot do, since the group count is what it is about to
+/// find out -- removed 49ms of it. That removed cost is all copying.
+///
+/// At these sizes the block is served by `mmap`, and `realloc` extends it with
+/// `mremap`: page-table work, no bytes moved. The elements it must still touch
+/// are only the NEW ones, which is the irreducible part.
+///
+/// Deliberately minimal: no shrink, no insert, no iterators. It is a slot array
+/// indexed by group id, and every use it has is `resize` / `data` / `[]`.
+///
+/// The `realloc`/`free` calls are the whole reason this class exists (in-place
+/// `mremap` growth, no copy) -- it is itself the RAII wrapper the check wants.
+// NOLINTBEGIN(cppcoreguidelines-no-malloc)
+template
+class SlotArray {
+ public:
+ static_assert(std::is_trivially_copyable_v);
+ static_assert(std::is_trivially_destructible_v);
+
+ SlotArray() = default;
+ SlotArray(const SlotArray&) = delete;
+ auto operator=(const SlotArray&) -> SlotArray& = delete;
+ SlotArray(SlotArray&& other) noexcept
+ : data_(other.data_), size_(other.size_), capacity_(other.capacity_) {
+ other.data_ = nullptr;
+ other.size_ = 0;
+ other.capacity_ = 0;
+ }
+ auto operator=(SlotArray&& other) noexcept -> SlotArray& {
+ if (this != &other) {
+ std::free(data_);
+ data_ = other.data_;
+ size_ = other.size_;
+ capacity_ = other.capacity_;
+ other.data_ = nullptr;
+ other.size_ = 0;
+ other.capacity_ = 0;
+ }
+ return *this;
+ }
+ ~SlotArray() { std::free(data_); }
+
+ [[nodiscard]] auto size() const noexcept -> std::size_t { return size_; }
+ [[nodiscard]] auto data() noexcept -> T* { return data_; }
+ [[nodiscard]] auto data() const noexcept -> const T* { return data_; }
+ auto operator[](std::size_t i) noexcept -> T& { return data_[i]; }
+ auto operator[](std::size_t i) const noexcept -> const T& { return data_[i]; }
+
+ /// Grow to `n` WITHOUT initializing the new tail, which is returned for the
+ /// caller to fill. Never shrinks the allocation: a group-by only ever adds
+ /// groups.
+ ///
+ /// Split out of `resize` because on a large array the fill is the expensive
+ /// half and it is not serial by nature. `mremap` hands back pages the kernel
+ /// has yet to materialize, so writing them is 72MB of first-touch page
+ /// faults on q18's 3M slots — 44ms in a cold process, several times what the
+ /// bytes alone cost, and page faults are what scales with threads. A caller
+ /// holding a worker pool fans this out; one without it calls `resize` and
+ /// pays the serial fill.
+ ///
+ /// How much this is worth depends on whether the pages are fresh, so read
+ /// the two numbers separately. Cold (`ibex query.ibex`, the path a script
+ /// takes) the fan-out takes q18's fill 44ms -> 34ms and the whole query
+ /// -4.3%. Warm — the PDS-H harness, which reuses one process, so the
+ /// allocator hands back pages already faulted — the fill is plain bandwidth
+ /// and the suite geomean does not move.
+ [[nodiscard]] auto grow_uninitialized(std::size_t n) -> std::span {
+ if (n <= size_) {
+ size_ = n;
+ return {};
+ }
+ if (n > capacity_) {
+ // Geometric, so a per-chunk resize does not call realloc once per
+ // chunk on a stream of many small chunks.
+ const std::size_t want = std::max(n, capacity_ + (capacity_ / 2));
+ auto* grown = static_cast(std::realloc(data_, want * sizeof(T)));
+ if (grown == nullptr) {
+ throw std::bad_alloc();
+ }
+ data_ = grown;
+ capacity_ = want;
+ }
+ const std::size_t old = size_;
+ size_ = n;
+ return {data_ + old, n - old};
+ }
+
+ /// Value-initialize `tail`, a range `grow_uninitialized` just handed back.
+ ///
+ /// One `memset` when `T`'s value-initialized form is all-zero bytes, which
+ /// every slot type here is (`AggSlotCore`'s two enums both start at 0). The
+ /// per-element copy this replaces cost q18's fill 12ms of its 55: three
+ /// million 24-byte `memcpy`s the compiler will not fuse, because it cannot
+ /// see that the prototype is zeros.
+ ///
+ /// Padding is why the test is a run-time `memcmp` rather than a
+ /// `static_assert`: value-initialization zeroes `T`'s padding too, so the
+ /// comparison is well defined here, but no constant expression can state
+ /// that for a type with padding. The loop keeps the class honest for a
+ /// future slot type whose default is not all zeros.
+ static void fill_default(std::span tail) noexcept {
+ if (tail.empty()) {
+ return;
+ }
+ const T prototype{};
+ alignas(T) std::array zero{};
+ // prototype{} zeroes padding too (see the class comment), so this is well defined.
+ // NOLINTNEXTLINE(cert-exp42-c,cert-flp37-c,bugprone-suspicious-memory-comparison)
+ if (std::memcmp(&prototype, zero.data(), sizeof(T)) == 0) {
+ // Through `void*`: `T` has default member initializers, so it is not
+ // trivially default-constructible and -Wclass-memaccess objects to
+ // memset-ing it directly. The memcmp above is what licenses this.
+ std::memset(static_cast(tail.data()), 0, tail.size() * sizeof(T));
+ return;
+ }
+ for (auto& slot : tail) {
+ std::memcpy(&slot, &prototype, sizeof(T));
+ }
+ }
+
+ /// Grow to `n`, value-initializing the new tail on the calling thread.
+ void resize(std::size_t n) { fill_default(grow_uninitialized(n)); }
+
+ private:
+ T* data_ = nullptr;
+ std::size_t size_ = 0;
+ std::size_t capacity_ = 0;
+};
+// NOLINTEND(cppcoreguidelines-no-malloc)
+
+auto bind_aggregate_columns(std::optional& columns, bool& bound,
+ const std::vector& group_by,
+ const std::vector& aggregations, const Chunk& chunk)
+ -> std::optional {
+ if (bound) {
+ return std::nullopt;
+ }
+ std::vector names;
+ names.reserve(chunk.columns.size());
+ for (const ColumnEntry& column : chunk.columns) {
+ names.push_back(column.name);
+ }
+ const bool concrete_layout_matches_plan = columns.has_value() &&
+ columns->input_names.size() == names.size() &&
+ std::ranges::equal(columns->input_names, names);
+ if (!concrete_layout_matches_plan) {
+ // Logical schema inference may know every source column while the
+ // physical child emits a narrower layout. A pushed-down filter, for
+ // example, can consume its predicate-only column inside a lazy scan and
+ // omit it from the chunks delivered to this breaker. Bind that actual
+ // boundary once; all row loops remain positional.
+ auto resolved = physical::resolve_aggregate_columns(group_by, aggregations, names);
+ if (!resolved.has_value()) {
+ return std::move(resolved.error());
+ }
+ columns = std::move(*resolved);
+ }
+ if (columns->group_by.size() != group_by.size() ||
+ columns->aggregate_inputs.size() != aggregations.size()) {
+ return "aggregate column mapping does not match aggregate shape";
+ }
+ for (std::size_t i = 0; i < columns->group_by.size(); ++i) {
+ const std::size_t index = columns->group_by[i];
+ if (index >= chunk.columns.size() || chunk.columns[index].name != group_by[i].name) {
+ return "aggregate group-by column mapping does not match concrete input";
+ }
+ }
+ for (std::size_t i = 0; i < columns->aggregate_inputs.size(); ++i) {
+ const auto index = columns->aggregate_inputs[i];
+ if (aggregations[i].func == ir::AggFunc::Count) {
+ if (index.has_value()) {
+ return "count aggregate unexpectedly has an input column mapping";
+ }
+ continue;
+ }
+ if (!index.has_value() || *index >= chunk.columns.size() ||
+ chunk.columns[*index].name != aggregations[i].column.name) {
+ std::string detail =
+ "aggregate input column mapping does not match concrete input: expected '" +
+ aggregations[i].column.name + "'";
+ if (index.has_value()) {
+ detail += " at position " + std::to_string(*index);
+ if (*index < chunk.columns.size()) {
+ detail += ", found '" + chunk.columns[*index].name + "'";
+ } else {
+ detail += ", but the input has only " + std::to_string(chunk.columns.size()) +
+ " columns";
+ }
+ }
+ return detail;
+ }
+ }
+ bound = true;
+ return std::nullopt;
+}
+
+class HashAggregateState final {
+ public:
+ /// `Cat` carries a Categorical's *code*, which the pair path may treat as
+ /// an integer for the same reason `process_rows_cat` may index an array
+ /// with it: within one operator a dictionary only ever grows and never
+ /// reorders, so a code identifies the same value in every chunk.
+ enum class IntKeyKind : std::uint8_t { Int64, Date, Ts, Cat };
+
+ HashAggregateState(OperatorPtr child, const std::vector* group_by,
+ const std::vector* aggregations, const ExecutionContext& exec,
+ physical::AggregateParallelism par = {},
+ std::optional columns = std::nullopt)
+ : child_(std::move(child)),
+ group_by_(group_by),
+ aggregations_(aggregations),
+ exec_(&exec),
+ columns_(std::move(columns)),
+ par_(par),
+ discovery_profile_(exec.execution_profile == nullptr
+ ? nullptr
+ : exec.execution_profile->stage("Aggregate.Discovery")),
+ accumulation_profile_(exec.execution_profile == nullptr
+ ? nullptr
+ : exec.execution_profile->stage("Aggregate.Accumulation")),
+ final_ordering_profile_(exec.execution_profile == nullptr
+ ? nullptr
+ : exec.execution_profile->stage("Aggregate.FinalOrdering")),
+ emission_profile_(exec.execution_profile == nullptr
+ ? nullptr
+ : exec.execution_profile->stage("Aggregate.Emission")) {}
+
+ /// Pull and run the structural Discovery node for one chunk. The chunk is
+ /// retained until `accumulate_discovery` consumes its transfer, so the
+ /// ColumnEntry pointers in that value remain valid without copying data.
+ auto next_discovery() -> std::expected {
+ if (input_consumed_) {
+ return false;
+ }
+ if (active_chunk_.has_value()) {
+ return std::unexpected(
+ "physical aggregate: Discovery advanced before Accumulation consumed its input");
+ }
+ auto chunk_res = child_->next();
+ if (!chunk_res.has_value()) {
+ return std::unexpected(std::move(chunk_res.error()));
+ }
+ if (!chunk_res.value().has_value()) {
+ input_consumed_ = true;
+ return false;
+ }
+ active_chunk_ = std::move(*chunk_res.value());
+ const ExecutionProfileScope scope(discovery_profile_, ProfilePhase::Next);
+ if (auto err = discover_chunk(*active_chunk_)) {
+ return std::unexpected(*err);
+ }
+ return true;
+ }
+
+ /// Consume the current Discovery output at the structural Accumulation
+ /// node, then release the input chunk before the source advances.
+ auto accumulate_discovery() -> std::expected {
+ if (!active_chunk_.has_value()) {
+ return std::unexpected("physical aggregate: Accumulation has no discovered chunk");
+ }
+ const ExecutionProfileScope scope(accumulation_profile_, ProfilePhase::Next);
+ if (auto err = accumulate_discovered_chunk()) {
+ return std::unexpected(*err);
+ }
+ active_chunk_.reset();
+ return {};
+ }
+
+ /// Structural FinalOrdering entry. Owned-partition strategies transfer
+ /// their local group state into deterministic first-occurrence order here;
+ /// already-global strategies have no deferred work at this boundary.
+ auto finalize_ordering() -> std::optional {
+ const ExecutionProfileScope scope(final_ordering_profile_, ProfilePhase::Next);
+ if (ordering_finalized_) {
+ return std::nullopt;
+ }
+ if (owned_mode_) {
+ finalize_owned_active();
+ if (owned_async_error_.has_value()) {
+ return owned_async_error_;
+ }
+ }
+ ordering_finalized_ = true;
+ return std::nullopt;
+ }
+
+ /// Structural Emission entry. It consumes only finalized, globally ordered
+ /// group state and constructs the result columns.
+ auto emit_output() -> std::expected, std::string> {
+ const ExecutionProfileScope scope(emission_profile_, ProfilePhase::Next);
+ if (!input_consumed_) {
+ return std::unexpected("physical aggregate: Emission ran before input consumption");
+ }
+ if (active_chunk_.has_value()) {
+ return std::unexpected("physical aggregate: Emission ran with unconsumed discovery");
+ }
+ if (!ordering_finalized_) {
+ return std::unexpected("physical aggregate: Emission ran before FinalOrdering");
+ }
+ if (emitted_) {
+ return std::optional{};
+ }
+ emitted_ = true;
+ return build_output_chunk();
+ }
+
+ private:
+ /// Telemetry for the breaker-parallelism slice (src/runtime/PARALLELISM.md).
+ /// The plan owns each phase's worker cap and fan-out permission now; the
+ /// fan-out output is byte-identical to serial, so without a counter a gate
+ /// that silently stopped matching would lose the parallelism with every
+ /// test green. Counted once per fan-out commit (per chunk for `partition`,
+ /// once for `finalize`, which runs once).
+ void note_partition_fanout() const {
+ if (exec_ != nullptr && exec_->parallel_stats != nullptr) {
+ exec_->parallel_stats->parallel_aggregate_partitions.fetch_add(
+ 1, std::memory_order_relaxed);
+ }
+ }
+ void note_finalize_fanout() const {
+ if (exec_ != nullptr && exec_->parallel_stats != nullptr) {
+ exec_->parallel_stats->parallel_aggregate_finalizes.fetch_add(
+ 1, std::memory_order_relaxed);
+ }
+ }
+
+ enum class DiscoveryTransferKind : std::uint8_t {
+ None,
+ NeedsAccumulation,
+ FusedAccumulation,
+ };
+
+ /// Per-chunk ownership transfer from Discovery to Accumulation. Column
+ /// pointers remain valid because `consume_input` keeps the owning Chunk
+ /// alive until `accumulate_discovered_chunk` consumes this value. The gid
+ /// buffer is operator-owned and cannot be reused until the transfer resets.
+ struct AggregateDiscoveryTransfer {
+ DiscoveryTransferKind kind = DiscoveryTransferKind::None;
+ std::vector aggregate_entries;
+ std::vector skip_fields;
+ std::size_t rows = 0;
+ };
+
+ void publish_discovered(const std::vector& aggregate_entries,
+ std::size_t rows,
+ const std::vector* skip_fields = nullptr) {
+ discovery_transfer_.kind = DiscoveryTransferKind::NeedsAccumulation;
+ discovery_transfer_.aggregate_entries = aggregate_entries;
+ discovery_transfer_.rows = rows;
+ discovery_transfer_.skip_fields =
+ skip_fields == nullptr ? std::vector{} : *skip_fields;
+ }
+
+ void publish_fused_accumulation() {
+ discovery_transfer_ = {};
+ discovery_transfer_.kind = DiscoveryTransferKind::FusedAccumulation;
+ }
+
+ auto accumulate_discovered_chunk() -> std::optional {
+ if (discovery_transfer_.kind == DiscoveryTransferKind::None) {
+ return "physical aggregate: Discovery produced no accumulation transfer";
+ }
+ if (discovery_transfer_.kind == DiscoveryTransferKind::FusedAccumulation) {
+ discovery_transfer_ = {};
+ return std::nullopt;
+ }
+ if (gids_buf_.size() < discovery_transfer_.rows) {
+ return "physical aggregate: Discovery produced a short group-id buffer";
+ }
+ const auto* skip =
+ discovery_transfer_.skip_fields.empty() ? nullptr : &discovery_transfer_.skip_fields;
+ accumulate_gids(gids_buf_.data(), discovery_transfer_.aggregate_entries,
+ discovery_transfer_.rows, skip);
+ discovery_transfer_ = {};
+ return std::nullopt;
+ }
+
+ auto discover_chunk(const Chunk& chunk) -> std::optional {
+ discovery_transfer_ = {};
+ if (std::getenv("IBEX_AGG_PARTITION_DEBUG") != nullptr) {
+ ibex::formatting::print(stderr, "[agg_process_chunk] rows={} group_by_size={}\n",
+ chunk.rows(), group_by_->size());
+ }
+ // Counted here, once per chunk, because the partition gate below asks
+ // how much input this OPERATOR has — a question the per-call row count
+ // stopped answering the moment sources began arriving in pieces.
+ rows_offered_ += chunk.rows();
+ if (auto err = bind_aggregate_columns(columns_, columns_bound_, *group_by_, *aggregations_,
+ chunk)) {
+ return err;
+ }
+ // `bind_aggregate_columns` only returns nullopt once `columns_` is bound.
+ if (!columns_.has_value()) {
+ return "HashAggregateState: column mapping not bound";
+ }
+ const physical::AggregateColumnMapping& cols = *columns_;
+ std::vector group_entries;
+ group_entries.reserve(group_by_->size());
+ for (const std::size_t index : cols.group_by) {
+ group_entries.push_back(&chunk.columns[index]);
+ }
+
+ std::vector agg_entries(aggregations_->size(), nullptr);
+ for (std::size_t i = 0; i < aggregations_->size(); ++i) {
+ const auto& agg = (*aggregations_)[i];
+ const std::optional& input_idx = cols.aggregate_inputs[i];
+ if (agg.func == ir::AggFunc::Count || !input_idx.has_value()) {
+ continue;
+ }
+ const ColumnEntry* entry = &chunk.columns[*input_idx];
+ const ExprType kind = expr_type_for_column(*entry->column);
+ const bool first_or_last =
+ agg.func == ir::AggFunc::First || agg.func == ir::AggFunc::Last;
+ // First/Last also accept String (which covers Column and
+ // Column — expr_type_for_column collapses both to
+ // String); every other function stays numeric-only.
+ const bool supported = kind == ExprType::Int || kind == ExprType::Double ||
+ (first_or_last && kind == ExprType::String);
+ if (!supported) {
+ return "HashAggregateState: non-numeric aggregation not supported";
+ }
+ agg_entries[i] = entry;
+ }
+
+ if (!initialized_) {
+ n_aggs_ = aggregations_->size();
+ plan_.reserve(n_aggs_);
+ for (std::size_t i = 0; i < n_aggs_; ++i) {
+ SlotPlan p;
+ p.func = (*aggregations_)[i].func;
+ if (p.func == ir::AggFunc::Count) {
+ p.kind = ExprType::Int;
+ } else {
+ p.kind = expr_type_for_column(*agg_entries[i]->column);
+ p.categorical =
+ std::holds_alternative>(*agg_entries[i]->column);
+ }
+ plan_.push_back(p);
+ }
+ // Lay the scratch out once the plan is known. Skew/Kurtosis share
+ // one online recurrence that updates both higher moments, so each
+ // asks for the pair.
+ scratch_offset_.assign(n_aggs_, 0);
+ scratch_stride_ = 0;
+ for (std::size_t i = 0; i < n_aggs_; ++i) {
+ // Scratch layout is [m2, m3, m4]. Stddev needs only the
+ // first; the higher moments imply it, since their recurrence
+ // reads m2 on every update.
+ if (plan_[i].func == ir::AggFunc::Stddev) {
+ plan_[i].scratch_doubles = 1;
+ } else if (plan_[i].func == ir::AggFunc::Skew ||
+ plan_[i].func == ir::AggFunc::Kurtosis) {
+ plan_[i].scratch_doubles = 3;
+ }
+ scratch_offset_[i] = static_cast(scratch_stride_);
+ scratch_stride_ += plan_[i].scratch_doubles;
+ }
+ group_templates_.reserve(group_entries.size());
+ bool all_cat = true;
+ for (const auto* e : group_entries) {
+ group_templates_.push_back(make_empty_like(*e->column));
+ if (!std::holds_alternative>(*e->column) ||
+ e->validity.has_value()) {
+ all_cat = false;
+ }
+ }
+ cat_fast_path_ = all_cat && !group_entries.empty();
+ // Single-string-key fast path: avoids the generic `Key`/ScalarValue
+ // variant path used by `process_rows_generic`. High-cardinality
+ // `sum by user_id` (~100K distinct strings in 2M rows) was spending
+ // most of its time constructing per-row ScalarValue variants and
+ // hashing them; the string path uses a string_view map keyed against
+ // an owned char/offset dictionary instead.
+ str_fast_path_ =
+ group_entries.size() == 1 &&
+ std::holds_alternative>(*group_entries[0]->column) &&
+ !group_entries[0]->validity.has_value();
+ // Single fixed-width-integer key: a direct value map, no owned Key.
+ const auto int_kind_of = [](const ColumnValue& col) -> std::optional {
+ if (std::holds_alternative>(col)) {
+ return IntKeyKind::Int64;
+ }
+ if (std::holds_alternative>(col)) {
+ return IntKeyKind::Date;
+ }
+ if (std::holds_alternative>(col)) {
+ return IntKeyKind::Ts;
+ }
+ return std::nullopt;
+ };
+ if (group_entries.size() == 1 && !group_entries[0]->validity.has_value()) {
+ if (auto kind = int_kind_of(*group_entries[0]->column)) {
+ int_fast_path_ = true;
+ int_key_kind_ = *kind;
+ }
+ } else if (!cat_fast_path_ && group_entries.size() == 2 &&
+ !group_entries[0]->validity.has_value() &&
+ !group_entries[1]->validity.has_value()) {
+ // A Categorical joins the pair path as its code. `cat_fast_path_`
+ // already owns the all-Categorical case and is dispatched first,
+ // so this is reached only by a *mixed* pair — `by { symbol, day }`
+ // over a Categorical and a Date, which otherwise fell to the
+ // generic path and hashed the symbol as text once per row.
+ const auto pair_kind_of = [&](const ColumnValue& col) -> std::optional {
+ if (std::holds_alternative>(col)) {
+ return IntKeyKind::Cat;
+ }
+ return int_kind_of(col);
+ };
+ auto ka = pair_kind_of(*group_entries[0]->column);
+ auto kb = pair_kind_of(*group_entries[1]->column);
+ if (ka.has_value() && kb.has_value()) {
+ pair_int_fast_path_ = true;
+ int_key_kind_ = *ka;
+ int_key_kind_b_ = *kb;
+ const auto is_32_bit = [](IntKeyKind k) {
+ return k == IntKeyKind::Cat || k == IntKeyKind::Date;
+ };
+ pair_packs_u64_ = is_32_bit(*ka) && is_32_bit(*kb);
+ }
+ } else if (group_entries.size() >= 3) {
+ // Three or more keys had no fast path at all: the branches above
+ // only recognise one key or two, so everything wider fell to
+ // `process_rows_generic`, which hashes a KeyCol tuple per row
+ // and hashes a Categorical as TEXT while doing it. If the whole
+ // key packs into a flat integer, `process_rows_packed` replaces
+ // that with one hash of a POD — and, because a packed key is
+ // something `try_discover_partitioned` can carry, threads the
+ // discovery too.
+ //
+ // The probe is discarded; the real plan is rebuilt per chunk,
+ // since a Categorical's remap is only valid for its own chunk.
+ packed_fast_path_ = encoder_.build_packed_key(group_entries).has_value();
+ }
+ initialized_ = true;
+ } else {
+ for (std::size_t i = 0; i < n_aggs_; ++i) {
+ if (plan_[i].func == ir::AggFunc::Count) {
+ continue;
+ }
+ const ExprType kind = expr_type_for_column(*agg_entries[i]->column);
+ if (kind != plan_[i].kind) {
+ return "HashAggregateState: aggregate column type changed across chunks";
+ }
+ }
+ for (std::size_t i = 0; i < group_entries.size(); ++i) {
+ if (group_entries[i]->column->index() != group_templates_[i].index()) {
+ return "HashAggregateState: group-by column type changed across chunks";
+ }
+ }
+ }
+
+ const std::size_t rows = chunk.rows();
+
+ // Global aggregate (`select { … }` with no `by`). Every row belongs to
+ // group 0, so the generic path below was running a hash probe per row
+ // against an EMPTY key just to rediscover that. Accumulate straight
+ // into the single group, and — since the groups are independent of row
+ // order — fan the row range out across workers.
+ if (group_entries.empty()) {
+ auto error = process_rows_ungrouped(agg_entries, rows);
+ if (!error.has_value()) {
+ publish_fused_accumulation();
+ }
+ return error;
+ }
+ // A fast-path index records only raw values/codes. It therefore cannot
+ // distinguish a later null from that value's zero/code representation.
+ // Parquet commonly omits an all-valid row group's bitmap, so this is a
+ // real streaming transition rather than a schema change visible in the
+ // first chunk.
+ //
+ // Every fast path stores its groups' raw values in a form the generic
+ // `KeyRowIndex` can be reseeded from -- `int_order_`/`str_order_`
+ // directly, `cat_order_`/`multi_cat_codes_flat_` via the dictionary
+ // `group_templates_` still holds, `pair_order_` via both, and the
+ // packed path's `group_order_` is already boxed `Key`s (see
+ // `migrate_packed_fast_path_to_generic`). Migrating only rebuilds the
+ // key->gid lookup; the accumulated `flat_slots_`/`scratch_` those gids
+ // already own are untouched, and this chunk then runs the generic path
+ // below like any other.
+ if ((cat_fast_path_ || str_fast_path_ || int_fast_path_ || pair_int_fast_path_ ||
+ packed_fast_path_) &&
+ std::ranges::any_of(group_entries, [](const ColumnEntry* entry) {
+ return entry->validity.has_value();
+ })) {
+ if (cat_fast_path_) {
+ migrate_cat_fast_path_to_generic(group_entries.size());
+ } else if (int_fast_path_) {
+ migrate_int_fast_path_to_generic();
+ } else if (str_fast_path_) {
+ migrate_str_fast_path_to_generic();
+ } else if (pair_int_fast_path_) {
+ migrate_pair_int_fast_path_to_generic();
+ } else if (packed_fast_path_) {
+ migrate_packed_fast_path_to_generic();
+ }
+ }
+ if (cat_fast_path_) {
+ return process_rows_cat(group_entries, agg_entries, rows);
+ }
+ if (str_fast_path_) {
+ return process_rows_str(group_entries, agg_entries, rows);
+ }
+ if (int_fast_path_) {
+ return process_rows_int(group_entries, agg_entries, rows);
+ }
+ if (pair_int_fast_path_) {
+ return process_rows_int_pair(group_entries, agg_entries, rows);
+ }
+ if (packed_fast_path_ && rows != 0) {
+ auto plan = encoder_.build_packed_key(group_entries);
+ if (!plan.has_value()) {
+ // The shape was packable when the first chunk fixed the path,
+ // so this is an unsupported mid-stream key-layout transition.
+ return "HashAggregateState: group-by key column gained nulls across chunks";
+ }
+ if (plan->width <= sizeof(std::uint64_t)) {
+ return process_rows_packed(group_entries, agg_entries, plan->cols, rows, packed64_);
+ }
+ if (plan->width <= sizeof(PackedKeyEncoder::Packed128)) {
+ return process_rows_packed(group_entries, agg_entries, plan->cols, rows,
+ packed128_);
+ }
+ return process_rows_packed(group_entries, agg_entries, plan->cols, rows, packed256_);
+ }
+ return process_rows_generic(group_entries, agg_entries, rows);
+ }
+
+ auto process_rows_str(const std::vector& group_entries,
+ const std::vector& agg_entries, std::size_t rows)
+ -> std::optional {
+ const auto& col = std::get>(*group_entries[0]->column);
+ const char* src_chars = col.chars_data();
+ const std::uint32_t* src_off = col.offsets_data();
+
+ gids_buf_.resize(rows);
+ auto* gids = gids_buf_.data();
+
+ // High-cardinality string keys are where this path pays: discovery is
+ // the serial half, and a string group-by has nothing else to hide it
+ // behind. Probing with a view keeps the owning copy per GROUP, as the
+ // serial loop below does.
+ const auto key_at = [&](std::size_t row) -> std::string_view {
+ return std::string_view{src_chars + src_off[row], src_off[row + 1] - src_off[row]};
+ };
+ if (try_discover_partitioned(
+ key_at, rows, gids, str_partitions_, [&](std::size_t n) { str_order_.resize(n); },
+ [&](const std::string& key, std::uint32_t gid, std::size_t) {
+ str_order_[gid] = key;
+ },
+ kDefaultPartitionMinRows,
+ [&](std::uint32_t gid) -> std::string_view { return str_order_[gid]; })) {
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ // Run-length shortcut: sorted or chunked CSV often has adjacent
+ // repeats; skip the hash lookup when the key matches the previous row.
+ std::string_view prev_key;
+ std::uint32_t prev_gid = std::numeric_limits::max();
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::string_view key{src_chars + src_off[row], src_off[row + 1] - src_off[row]};
+ std::uint32_t gid{};
+ if (key == prev_key) {
+ gid = prev_gid;
+ } else {
+ // Transparent lookup on string_view avoids constructing a
+ // std::string per probe. Insertions pay one std::string
+ // construction per novel key — with libstdc++'s 15-char SSO,
+ // 11-char user_id strings stay inline (no heap alloc).
+ auto it = str_index_.find(key);
+ if (it == str_index_.end()) {
+ gid = static_cast(n_groups_);
+ str_index_.emplace(std::string(key), gid);
+ str_order_.emplace_back(key);
+ ++n_groups_;
+ size_group_arrays();
+ } else {
+ gid = static_cast(it->second);
+ }
+ prev_key = key;
+ prev_gid = gid;
+ }
+ gids[row] = gid;
+ }
+
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ // Single fixed-width-integer key: probe a value -> gid map directly, the way
+ // process_rows_str does for strings. Date/Timestamp are read as their raw
+ // integer (days / nanos), which is order- and equality-faithful.
+ auto process_rows_int(const std::vector& group_entries,
+ const std::vector& agg_entries, std::size_t rows)
+ -> std::optional {
+ const ColumnValue& key_col = *group_entries[0]->column;
+ const std::int64_t* i64 = nullptr;
+ const Date* dates = nullptr;
+ const Timestamp* stamps = nullptr;
+ switch (int_key_kind_) {
+ case IntKeyKind::Int64:
+ i64 = std::get>(key_col).data();
+ break;
+ case IntKeyKind::Date:
+ dates = std::get>(key_col).data();
+ break;
+ case IntKeyKind::Ts:
+ stamps = std::get>(key_col).data();
+ break;
+ case IntKeyKind::Cat:
+ // A lone Categorical key never selects this path: it is
+ // all-Categorical by definition, so `cat_fast_path_` claims it
+ // and dispatches first. Only the pair path admits `Cat`.
+ return "HashAggregateState: categorical key on the single-int path";
+ }
+ const auto key_at = [&](std::size_t row) -> std::int64_t {
+ switch (int_key_kind_) {
+ case IntKeyKind::Int64:
+ return i64[row];
+ case IntKeyKind::Date:
+ return dates[row].days;
+ case IntKeyKind::Ts:
+ return stamps[row].nanos;
+ case IntKeyKind::Cat:
+ break;
+ }
+ return 0;
+ };
+
+ // The q18 shape (one Int64 key and one Double sum) is a streaming sink,
+ // not a sequence of per-chunk fork/join pipelines. Each chunk becomes
+ // one independent hot-table task; the caller immediately pulls the
+ // next chunk, and all tasks join once at end-of-stream. Besides removing
+ // the three barriers per chunk, this keeps the common clustered key in
+ // a 4096-slot cache-resident reduction and sends only cold/pre-aggregated
+ // records to the persistent partition maps.
+ if (try_async_hot_int_sum(group_entries[0]->column, agg_entries, rows)) {
+ publish_fused_accumulation();
+ return std::nullopt;
+ }
+
+ gids_buf_.resize(rows);
+ auto* gids = gids_buf_.data();
+
+ // A non-null First value is fixed at the same row that creates its
+ // group. Record it during discovery and omit the later all-row scan.
+ // Group-key reduction turns q10's six descriptive keys into exactly
+ // this shape; scanning 229k rows once per carried field was redundant.
+ if (discovery_first_eligible_.empty()) {
+ discovery_first_eligible_.resize(n_aggs_, 0U);
+ for (std::size_t a = 0; a < n_aggs_; ++a) {
+ discovery_first_eligible_[a] = plan_[a].func == ir::AggFunc::First ? 1U : 0U;
+ }
+ }
+ std::vector discovery_first(n_aggs_, 0U);
+ bool has_discovery_first = false;
+ if (std::getenv("IBEX_DISABLE_DISCOVERY_FIRST") == nullptr) {
+ for (std::size_t a = 0; a < n_aggs_; ++a) {
+ if (discovery_first_eligible_[a] == 0U) {
+ continue;
+ }
+ if (agg_entries[a]->validity.has_value()) {
+ // A group may still be waiting for its first non-null
+ // value. Keep this field on the ordinary scan for every
+ // later chunk too, even if that later chunk has no nulls.
+ discovery_first_eligible_[a] = 0U;
+ continue;
+ }
+ discovery_first[a] = 1U;
+ has_discovery_first = true;
+ }
+ }
+ const std::size_t groups_before = n_groups_;
+ std::vector first_rows;
+
+ // Partition-owned accumulation, the same shape the PairIntKey path
+ // above takes. It fuses discovery and the sum/count into one pass over
+ // partition-local state, so the global first-occurrence numbering --
+ // and with it the whole second `accumulate_gids` scan of every row --
+ // is deferred to a single merge over GROUPS at emission.
+ //
+ // `int_order_` holds the raw key whatever `int_key_kind_` is; the emit
+ // side reconstructs Date/Timestamp/Categorical from it exactly as it
+ // does for the ordinary int path, so this needs no kind-specific arm.
+ if (try_owned>(
+ key_at, rows, gids, agg_entries, owned_int_partitions_, kIntOwnedMinRows)) {
+ publish_fused_accumulation();
+ return std::nullopt;
+ }
+
+ if (try_discover_partitioned>(
+ key_at, rows, gids, int_partitions_,
+ [&](std::size_t n) {
+ int_order_.resize(n);
+ if (has_discovery_first) {
+ first_rows.resize(n - groups_before);
+ }
+ },
+ [&](std::int64_t key, std::uint32_t gid, std::size_t row) {
+ int_order_[gid] = key;
+ if (has_discovery_first) {
+ first_rows[gid - groups_before] = row;
+ }
+ },
+ kDefaultPartitionMinRows, [&](std::uint32_t gid) { return int_order_[gid]; })) {
+ if (has_discovery_first) {
+ seed_discovery_first(groups_before, first_rows, agg_entries, discovery_first);
+ }
+ publish_discovered(agg_entries, rows, has_discovery_first ? &discovery_first : nullptr);
+ return std::nullopt;
+ }
+
+ // Run-length shortcut, as in the string path: sorted/chunked input often
+ // repeats the key, so skip the map lookup when it matches the last row.
+ std::int64_t prev_key = 0;
+ std::uint32_t prev_gid = std::numeric_limits::max();
+ bool have_prev = false;
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::int64_t key = key_at(row);
+ std::uint32_t gid{};
+ if (have_prev && key == prev_key) {
+ gid = prev_gid;
+ } else {
+ auto it = int_index_.find(key);
+ if (it == int_index_.end()) {
+ gid = static_cast(n_groups_);
+ int_index_.emplace(key, gid);
+ int_order_.push_back(key);
+ ++n_groups_;
+ size_group_arrays();
+ if (has_discovery_first) {
+ first_rows.push_back(row);
+ }
+ } else {
+ gid = it->second;
+ }
+ prev_key = key;
+ prev_gid = gid;
+ have_prev = true;
+ }
+ gids[row] = gid;
+ }
+
+ if (has_discovery_first) {
+ seed_discovery_first(groups_before, first_rows, agg_entries, discovery_first);
+ }
+ publish_discovered(agg_entries, rows, has_discovery_first ? &discovery_first : nullptr);
+ return std::nullopt;
+ }
+
+ static constexpr std::size_t kOwnedHotSlots = 4096;
+
+ struct OwnedHotRecord {
+ std::int64_t key = 0;
+ std::uint64_t first_row = 0;
+ AggSlotCore slot;
+ };
+
+ struct OwnedHotChunk {
+ std::shared_ptr key_column;
+ std::shared_ptr sum_column;
+ std::optional sum_validity;
+ std::uint64_t row_base = 0;
+ std::size_t rows = 0;
+ std::size_t part_count = 0;
+ std::vector> records_by_partition;
+ std::optional error;
+ };
+
+ struct OwnedHotSlot {
+ std::uint32_t tag = std::numeric_limits::max();
+ std::uint32_t last_access_tag = std::numeric_limits::max();
+ std::uint32_t record = std::numeric_limits::max();
+ };
+
+ [[nodiscard]] static auto owned_hot_hash(std::int64_t key) noexcept -> std::uint64_t {
+ // robin_hood's Int64 hash preserves too much of a sequential key's bit
+ // pattern for a high-bit fixed table. SplitMix's finalizer gives both
+ // candidate slots and the tag independent-looking bits at tiny cost.
+ auto x = static_cast(key);
+ x ^= x >> 30U;
+ x *= 0xbf58476d1ce4e5b9ULL;
+ x ^= x >> 27U;
+ x *= 0x94d049bb133111ebULL;
+ x ^= x >> 31U;
+ return x;
+ }
+
+ static void process_owned_hot_chunk(OwnedHotChunk& job) noexcept {
+ try {
+ const auto* keys = std::get>(*job.key_column).data();
+ const auto* values = std::get>(*job.sum_column).data();
+ const ValidityBitmap* validity =
+ job.sum_validity.has_value() ? &*job.sum_validity : nullptr;
+ constexpr std::uint32_t kEmpty = std::numeric_limits::max();
+ constexpr unsigned kShift = 64U - 12U;
+ constexpr std::uint64_t kH2Mult = 0xf1357aea2e62a9c5ULL;
+
+ std::array table{};
+ std::vector records;
+ records.reserve(std::max(kOwnedHotSlots, job.rows / 3));
+ std::size_t filled = 0;
+ std::uint64_t prng = 0;
+ std::int64_t last_key = 0;
+ std::uint32_t last_record = kEmpty;
+ bool have_last = false;
+
+ const auto append_record = [&](std::size_t row) -> std::uint32_t {
+ const auto index = static_cast(records.size());
+ OwnedHotRecord record;
+ record.key = keys[row];
+ record.first_row = job.row_base + row;
+ if (validity == nullptr || (*validity)[row]) {
+ record.slot.double_value = values[row];
+ record.slot.mark_present();
+ }
+ records.push_back(record);
+ return index;
+ };
+ const auto update_record = [&](std::uint32_t record, std::size_t row) {
+ if (validity == nullptr || (*validity)[row]) {
+ auto& slot = records[record].slot;
+ slot.double_value += values[row];
+ slot.mark_present();
+ }
+ };
+
+ for (std::size_t row = 0; row < job.rows; ++row) {
+ const std::int64_t key = keys[row];
+ // HashKeys in Polars computes hashes as a column kernel before
+ // probing. Here keys arrive as raw Int64s, and q18's useful
+ // locality is specifically runs of the same key. Retain the
+ // most recent hot/cold record so the other rows in a run avoid
+ // both the SplitMix hash and the two fixed-table probes.
+ if (have_last && key == last_key) {
+ update_record(last_record, row);
+ continue;
+ }
+ const std::uint64_t hash = owned_hot_hash(key);
+ const auto tag = static_cast(hash);
+ const auto h1 = static_cast(hash >> kShift);
+ const auto h2 = static_cast((hash * kH2Mult) >> kShift);
+ auto& s1 = table[h1];
+ auto& s2 = table[h2];
+
+ if (s1.tag == tag && s1.record != kEmpty && records[s1.record].key == key) {
+ s1.last_access_tag = tag;
+ update_record(s1.record, row);
+ last_key = key;
+ last_record = s1.record;
+ have_last = true;
+ continue;
+ }
+ if (s2.tag == tag && s2.record != kEmpty && records[s2.record].key == key) {
+ s2.last_access_tag = tag;
+ update_record(s2.record, row);
+ last_key = key;
+ last_record = s2.record;
+ have_last = true;
+ continue;
+ }
+
+ if (filled < kOwnedHotSlots) {
+ OwnedHotSlot* empty = s1.record == kEmpty ? &s1 : nullptr;
+ if (empty == nullptr && s2.record == kEmpty) {
+ empty = &s2;
+ }
+ if (empty != nullptr) {
+ empty->tag = tag;
+ empty->last_access_tag = tag;
+ empty->record = append_record(row);
+ last_key = key;
+ last_record = empty->record;
+ have_last = true;
+ ++filled;
+ continue;
+ }
+ }
+
+ // Polars' second chance: a miss first marks one candidate as
+ // recently considered and stays cold. Seeing the same tag
+ // again earns admission, evicting that candidate's mapping;
+ // its record already contains the complete pre-aggregate.
+ OwnedHotSlot& chosen = (prng >> 63U) != 0 ? s1 : s2;
+ prng += hash;
+ if (chosen.last_access_tag == tag) {
+ chosen.tag = tag;
+ chosen.last_access_tag = tag;
+ chosen.record = append_record(row);
+ last_record = chosen.record;
+ } else {
+ chosen.last_access_tag = tag;
+ last_record = append_record(row);
+ }
+ last_key = key;
+ have_last = true;
+ }
+
+ // Records were appended at their first source row and only updated
+ // in place, so this stable routing preserves global first-seen
+ // order without a histogram/scatter phase or a sort.
+ job.records_by_partition.resize(job.part_count);
+ std::vector counts(job.part_count, 0);
+ const robin_hood::hash partition_hash;
+ const std::size_t mask = job.part_count - 1;
+ for (const auto& record : records) {
+ ++counts[partition_hash(record.key) & mask];
+ }
+ for (std::size_t p = 0; p < job.part_count; ++p) {
+ job.records_by_partition[p].reserve(counts[p]);
+ }
+ for (auto& record : records) {
+ const std::size_t p = partition_hash(record.key) & mask;
+ job.records_by_partition[p].push_back(record);
+ }
+
+ // Release decoded buffers as soon as this task is done. The job's
+ // compact pre-aggregates remain until the one final cold merge.
+ job.key_column.reset();
+ job.sum_column.reset();
+ job.sum_validity.reset();
+ } catch (const std::exception& error) {
+ job.error = "async hot aggregate: " + std::string(error.what());
+ } catch (...) {
+ job.error = "async hot aggregate: non-standard worker exception";
+ }
+ }
+
+ auto try_async_hot_int_sum(const std::shared_ptr& key_column,
+ const std::vector& agg_entries, std::size_t rows)
+ -> bool {
+ if (!owned_async_hot_mode_) {
+ if (std::getenv("IBEX_DISABLE_OWNED_PAIR_AGG") != nullptr ||
+ std::getenv("IBEX_DISABLE_ASYNC_HOT_AGG") != nullptr || n_groups_ > 0 ||
+ partitioned_active_ || owned_mode_ || n_aggs_ != 1 ||
+ plan_[0].func != ir::AggFunc::Sum || plan_[0].kind != ExprType::Double ||
+ int_key_kind_ != IntKeyKind::Int64 || scratch_stride_ != 0 || exec_ == nullptr ||
+ on_worker_pool_thread() || std::max(rows_offered_, rows) < kIntOwnedMinRows) {
+ return false;
+ }
+ // As `try_owned`: fan-out permission and the worker cap are the
+ // plan's Discovery node (src/runtime/PARALLELISM.md);
+ // `kIntOwnedMinRows` stays as this specialization's admission gate.
+ if (par_.discovery.decline != physical::FanOutDecline::None ||
+ par_.discovery.worker_cap < 2) {
+ return false;
+ }
+ auto& pool = process_worker_pool();
+ const std::size_t workers = par_.discovery.worker_cap;
+ owned_async_part_count_ = 1;
+ while (owned_async_part_count_ * 2 <= workers) {
+ owned_async_part_count_ *= 2;
+ }
+ owned_int_partitions_.resize(owned_async_part_count_);
+ owned_async_group_.emplace(pool.task_group());
+ owned_async_hot_mode_ = true;
+ owned_mode_ = true;
+ note_partition_fanout();
+ }
+
+ const ColumnEntry& agg0 = *agg_entries[0];
+ auto job = std::make_unique();
+ job->key_column = key_column;
+ job->sum_column = agg0.column;
+ if (agg0.validity.has_value()) {
+ job->sum_validity = *agg0.validity;
+ }
+ job->row_base = owned_rows_seen_;
+ job->rows = rows;
+ job->part_count = owned_async_part_count_;
+ auto* const raw_job = job.get();
+ owned_async_jobs_.push_back(std::move(job));
+ // Engaged since the block above either emplaced it or `owned_async_hot_mode_`
+ // was already set (the two only ever change together).
+ if (!owned_async_group_.has_value()) {
+ invariant_violation("async hot aggregate: task group missing while accepting chunks");
+ }
+ owned_async_group_->submit([raw_job] { process_owned_hot_chunk(*raw_job); });
+ owned_rows_seen_ += rows;
+ return true;
+ }
+
+ /// Production ownership threshold for the narrow PairIntKey path below,
+ /// backed by a synthetic row/cardinality sweep (32k/64k/128k/262144 rows
+ /// x low/high cardinality, 8 cores, 6 interleaved rounds): 32k showed no
+ /// reliable win (3/6 wins, ~0%), 64k was the first point with a
+ /// consistent, real one (6/6 wins, -6% to -10%), and 128k/262144 stayed
+ /// positive. Deliberately NOT `kDefaultPartitionMinRows` (262144, the
+ /// threshold `try_discover_partitioned` uses): that value was tuned for a
+ /// different mechanism (discovery only, no fused accumulation, no
+ /// deferred merge) and is not evidence for where THIS path's overhead
+ /// breaks even -- q20's own chunks (~150k rows) sit between the two.
+ static constexpr std::size_t kPairOwnedMinRows = 1U << 16U; // 65536
+
+ /// Same gate for the single-Int64-key slice. Held at the pair path's value
+ /// until the sweep below says otherwise -- the mechanism is identical and
+ /// its break-even has no reason to differ by more than the key's own probe
+ /// cost, which is lower, not higher.
+ static constexpr std::size_t kIntOwnedMinRows = 1U << 16U; // 65536
+
+ /// Production PairIntKey ownership path (TPC-H q20's
+ /// `by { l_partkey, l_suppkey }` is the motivating shape; validated
+ /// there at -16.5%, 8/8 paired wins, 8 cores, vs. a q18/Int64 prototype
+ /// that measured only -7.6%, was never promoted, and has since been
+ /// removed -- see plans/parallelism-overview.md). Deliberately narrow:
+ ///
+ /// - Exactly one aggregate, Sum(Double) or Count. q18 and q20 both only
+ /// ever exercise one, so nothing measures whether row-wise fusion beats
+ /// partition-outer/aggregate-outer accumulation once a query carries
+ /// several -- widen only after that shape is actually benchmarked.
+ /// - Row-wise fusion only: with exactly one aggregate a second full row
+ /// scan can only add cost, never locality it does not already have.
+ /// - No env-var mode selector: this runs whenever eligible, the same way
+ /// `try_discover_partitioned` has no toggle either. `IBEX_DISABLE_
+ /// OWNED_PAIR_AGG=1` is a kill switch for the unusual case that needs
+ /// one, not a normal control surface.
+ template
+ auto try_owned(const KeyAt& key_at, std::size_t rows, std::uint32_t* gids,
+ const std::vector& agg_entries, Partitions& partitions,
+ std::size_t min_rows) -> bool {
+ if (std::getenv("IBEX_DISABLE_OWNED_PAIR_AGG") != nullptr) {
+ return false;
+ }
+ if (!owned_mode_) {
+ if (n_groups_ > 0 || partitioned_active_) {
+ return false;
+ }
+ if (n_aggs_ != 1) {
+ return false;
+ }
+ if (plan_[0].func != ir::AggFunc::Sum && plan_[0].func != ir::AggFunc::Count) {
+ return false;
+ }
+ if (plan_[0].func == ir::AggFunc::Sum && plan_[0].kind != ExprType::Double) {
+ return false;
+ }
+ if (scratch_stride_ != 0) {
+ return false;
+ }
+ if (exec_ == nullptr || on_worker_pool_thread()) {
+ return false;
+ }
+ // Fan-out permission and the worker cap are the plan's
+ // (src/runtime/PARALLELISM.md); `decline != None` folds in
+ // `!exec_->can_fan_out()`. `min_rows` stays here: it is the owned
+ // strategy's own "is the specialization worth it" gate, lower than
+ // Discovery's radix floor, and an operator-resolved choice like
+ // the join's build orientation.
+ if (par_.discovery.decline != physical::FanOutDecline::None ||
+ par_.discovery.worker_cap < 2) {
+ return false;
+ }
+ if (std::max(rows_offered_, rows) < min_rows) {
+ return false;
+ }
+ }
+
+ auto& pool = process_worker_pool();
+ const std::size_t workers = par_.discovery.worker_cap;
+ std::size_t part_count = 1;
+ while (part_count * 2 <= workers) {
+ part_count *= 2;
+ }
+ const std::uint64_t part_mask = part_count - 1;
+ if (partitions.size() < part_count) {
+ partitions.resize(part_count);
+ }
+ note_partition_fanout();
+
+ // A clustered count key should not pay the partition/scatter/hash
+ // pipeline once per ROW. Compress contiguous equal-key runs first and
+ // carry their lengths as count weights. This is exact for arbitrary
+ // input order: non-contiguous runs still meet in the exact hash
+ // fallback, while sorted inputs such as TPC-H lineitem reduce the
+ // expensive part of the pipeline by roughly their mean run length.
+ // Sample before committing because all-unique keys would only add two
+ // equality passes and retain the original item count.
+ // NOLINTNEXTLINE(misc-const-correctness) -- mutated in the int64 instantiation below
+ [[maybe_unused]] bool compress_runs = false;
+ if constexpr (std::is_same_v) {
+ compress_runs = owned_ordered_run_mode_;
+ if (!compress_runs && !owned_mode_ && plan_[0].func == ir::AggFunc::Count &&
+ rows >= 64 && std::getenv("IBEX_DISABLE_ORDERED_RUN_AGG") == nullptr) {
+ const std::size_t sampled = std::min(rows, 8192);
+ std::size_t repeats = 0;
+ Key previous = key_at(0);
+ for (std::size_t row = 1; row < sampled; ++row) {
+ const Key key = key_at(row);
+ repeats += key == previous ? 1 : 0;
+ previous = key;
+ }
+ compress_runs = repeats * 2 >= sampled - 1;
+ }
+ }
+
+ const std::size_t source_ranges = workers;
+ const std::size_t source_grain = (rows + source_ranges - 1) / source_ranges;
+
+ // Run compression only ever engages for a clustered single-Int64 Count
+ // (the `if constexpr` above is the only writer of `compress_runs`).
+ // Two parallel passes, no scratch: pass 1 counts runs per range so the
+ // per-range output offsets are contiguous, pass 2 emits (key, length)
+ // straight into `owned_ordered_run_{keys,counts}_`. A third pass to
+ // dereference anchor rows -- and the `owned_run_rows_/_lengths_` arrays
+ // it read -- used to sit between them; folding it into pass 2 drops one
+ // pool barrier per chunk, which is q21's `count() by l_orderkey` hot
+ // path (~23 chunks, this was 3 barriers each).
+ if constexpr (std::is_same_v) {
+ if (compress_runs) {
+ std::vector run_offsets(source_ranges + 1, 0);
+ {
+ auto batch = pool.submit(source_ranges, [&](std::size_t r) {
+ const std::size_t begin = r * source_grain;
+ const std::size_t end = std::min(rows, begin + source_grain);
+ if (begin >= end) {
+ return;
+ }
+ std::size_t runs = 1;
+ Key previous = key_at(begin);
+ for (std::size_t row = begin + 1; row < end; ++row) {
+ const Key key = key_at(row);
+ runs += key == previous ? 0 : 1;
+ previous = key;
+ }
+ run_offsets[r + 1] = runs;
+ });
+ batch.wait();
+ }
+ for (std::size_t r = 0; r < source_ranges; ++r) {
+ run_offsets[r + 1] += run_offsets[r];
+ }
+ const std::size_t items = run_offsets.back();
+ const std::size_t old_runs = owned_ordered_run_keys_.size();
+ owned_ordered_run_keys_.resize(old_runs + items);
+ owned_ordered_run_counts_.resize(old_runs + items);
+ {
+ auto batch = pool.submit(source_ranges, [&](std::size_t r) {
+ const std::size_t begin = r * source_grain;
+ const std::size_t end = std::min(rows, begin + source_grain);
+ if (begin >= end) {
+ return;
+ }
+ std::size_t out = old_runs + run_offsets[r];
+ std::size_t anchor = begin;
+ Key previous = key_at(begin);
+ for (std::size_t row = begin + 1; row < end; ++row) {
+ const Key key = key_at(row);
+ if (key != previous) {
+ owned_ordered_run_keys_[out] = previous;
+ owned_ordered_run_counts_[out] = row - anchor;
+ ++out;
+ anchor = row;
+ previous = key;
+ }
+ }
+ owned_ordered_run_keys_[out] = previous;
+ owned_ordered_run_counts_[out] = end - anchor;
+ });
+ batch.wait();
+ }
+ if (owned_ordered_runs_nondecreasing_) {
+ const std::size_t from = old_runs == 0 ? 1 : old_runs;
+ for (std::size_t i = from; i < old_runs + items; ++i) {
+ if (owned_ordered_run_keys_[i] < owned_ordered_run_keys_[i - 1]) {
+ owned_ordered_runs_nondecreasing_ = false;
+ break;
+ }
+ }
+ }
+ owned_rows_seen_ += rows;
+ owned_mode_ = true;
+ owned_ordered_run_mode_ = true;
+ return true;
+ }
+ }
+
+ const std::size_t ranges = workers;
+ const std::size_t grain = (rows + ranges - 1) / ranges;
+ part_of_row_.resize(rows);
+ std::vector counts(ranges * part_count, 0);
+ {
+ auto batch = pool.submit(ranges, [&](std::size_t r) {
+ const std::size_t begin = r * grain;
+ const std::size_t end = std::min(rows, begin + grain);
+ std::size_t* row_counts = counts.data() + (r * part_count);
+ Hash hasher;
+ for (std::size_t row = begin; row < end; ++row) {
+ const auto part = static_cast(hasher(key_at(row)) & part_mask);
+ part_of_row_[row] = part;
+ ++row_counts[part];
+ }
+ });
+ batch.wait();
+ }
+ std::vector offsets(ranges * part_count, 0);
+ std::vector part_begin(part_count + 1, 0);
+ {
+ std::size_t running = 0;
+ for (std::size_t p = 0; p < part_count; ++p) {
+ part_begin[p] = running;
+ for (std::size_t r = 0; r < ranges; ++r) {
+ offsets[(r * part_count) + p] = running;
+ running += counts[(r * part_count) + p];
+ }
+ }
+ part_begin[part_count] = running;
+ }
+ scatter_rows_.resize(rows);
+ {
+ auto batch = pool.submit(ranges, [&](std::size_t r) {
+ const std::size_t begin = r * grain;
+ const std::size_t end = std::min(rows, begin + grain);
+ std::size_t* cursor = offsets.data() + (r * part_count);
+ for (std::size_t row = begin; row < end; ++row) {
+ scatter_rows_[cursor[part_of_row_[row]]++] = row;
+ }
+ });
+ batch.wait();
+ }
+
+ std::vector sum_cols(n_aggs_, nullptr);
+ std::vector sum_validity(n_aggs_, nullptr);
+ std::vector is_count(n_aggs_, 0);
+ for (std::size_t a = 0; a < n_aggs_; ++a) {
+ if (plan_[a].func == ir::AggFunc::Count) {
+ is_count[a] = 1;
+ continue;
+ }
+ const ColumnEntry& entry = *agg_entries[a];
+ sum_cols[a] = std::get>(*entry.column).data();
+ sum_validity[a] = entry.validity.has_value() ? &*entry.validity : nullptr;
+ }
+
+ const std::uint64_t row_base = owned_rows_seen_;
+ {
+ std::atomic cursor{0};
+ auto batch = pool.submit(std::min(workers, part_count), [&](std::size_t) {
+ for (std::size_t p = cursor.fetch_add(1, std::memory_order_relaxed); p < part_count;
+ p = cursor.fetch_add(1, std::memory_order_relaxed)) {
+ auto& partition = partitions[p];
+ for (std::size_t i = part_begin[p]; i < part_begin[p + 1]; ++i) {
+ const std::size_t row = scatter_rows_[i];
+ const Key key = key_at(row);
+ auto it = partition.index.find(key);
+ std::uint32_t local{};
+ if (it == partition.index.end()) {
+ local = static_cast(partition.keys.size());
+ partition.index.emplace(key, local);
+ partition.keys.push_back(key);
+ partition.first_rows.push_back(row_base + row);
+ partition.slots.resize((local + 1) * n_aggs_);
+ } else {
+ local = it->second;
+ }
+ gids[row] = local;
+ // n_aggs_ == 1 is enforced above -- this is a single
+ // slot update, not a loop over aggregates. Written as
+ // one, not unrolled, so a future widening to >1
+ // aggregate (once actually measured, per the
+ // class-level comment) is a small diff here.
+ AggSlotCore& slot = partition.slots[local];
+ if (is_count[0] != 0) {
+ ++slot.count;
+ } else if (sum_validity[0] == nullptr || (*sum_validity[0])[row]) {
+ slot.double_value += sum_cols[0][row];
+ slot.mark_present();
+ }
+ }
+ }
+ });
+ batch.wait();
+ }
+
+ owned_rows_seen_ += rows;
+ owned_mode_ = true;
+ if (std::getenv("IBEX_AGG_PARTITION_DEBUG") != nullptr) {
+ ibex::formatting::print(
+ stderr, "[agg_owned] chunk rows={} part_count={} total_rows={}\n", rows,
+ partitions.size(), static_cast(owned_rows_seen_));
+ }
+ return true;
+ }
+
+ /// Walk every `owned_pair_partitions_` entry once, in first-occurrence
+ /// order (a P-way merge over `first_rows`, run once at final emission),
+ /// and populate `pair_order_`/`flat_slots_` -- the arrays
+ /// `build_output_chunk`'s `pair_int_fast_path_` branch already reads.
+ template
+ void finalize_owned(Partitions& partitions, const ResizeOrder& resize_order,
+ const StoreKey& store_key) {
+ if (owned_finalized_) {
+ return;
+ }
+ owned_finalized_ = true;
+ const std::size_t part_count = partitions.size();
+ std::size_t total = 0;
+ for (const auto& partition : partitions) {
+ total += partition.keys.size();
+ }
+ n_groups_ = total;
+ resize_order(total);
+ AggSlotCore* fs = flat_slots_.grow_uninitialized(total * n_aggs_).data();
+ if (total == 0) {
+ return;
+ }
+
+ // K-way merge of the `part_count` partition group-lists by `first_rows`
+ // into the output at ascending `g`. Every partition's `first_rows` is
+ // strictly ascending and the values are globally unique (they are row
+ // indices, and each row belongs to one partition), so this is a stable
+ // merge over a total order -- byte-identical however the segments below
+ // are split.
+ const auto merge_segment = [&](std::vector cur,
+ const std::vector& stop, std::size_t g) {
+ for (;;) {
+ std::size_t best = part_count;
+ std::uint64_t best_row = std::numeric_limits::max();
+ for (std::size_t p = 0; p < part_count; ++p) {
+ if (cur[p] >= stop[p]) {
+ continue;
+ }
+ const std::uint64_t fr = partitions[p].first_rows[cur[p]];
+ if (fr < best_row) {
+ best_row = fr;
+ best = p;
+ }
+ }
+ if (best == part_count) {
+ break;
+ }
+ const auto& partition = partitions[best];
+ const std::size_t local = cur[best];
+ store_key(g, partition.keys[local]);
+ for (std::size_t a = 0; a < n_aggs_; ++a) {
+ fs[(g * n_aggs_) + a] = partition.slots[(local * n_aggs_) + a];
+ }
+ ++cur[best];
+ ++g;
+ }
+ };
+
+ std::vector part_end(part_count);
+ for (std::size_t p = 0; p < part_count; ++p) {
+ part_end[p] = partitions[p].keys.size();
+ }
+
+ // For q18's 3M groups the serial merge is ~24M comparisons plus 3M
+ // slot copies -- tens of ms on the calling thread. Split the OUTPUT
+ // into per-worker rank ranges via merge-path co-ranking: since every
+ // `first_rows` value is unique, `sum_p lower_bound(first_rows_p, v)`
+ // steps by exactly one at each value and so equals any target rank at
+ // exactly one `v`. Each worker then merges the disjoint input slices
+ // between two frontiers into its disjoint output slice.
+ // The FinalOrdering node's worker ceiling and fan-out permission are the
+ // plan's (src/runtime/PARALLELISM.md); `part_count` and `total / 4096`
+ // stay here -- they need the group count discovery just produced. The
+ // `1U << 17U` group floor is this merge's own threshold, beside it.
+ std::size_t workers = 1;
+ if (exec_ != nullptr && par_.final_ordering.decline == physical::FanOutDecline::None &&
+ !on_worker_pool_thread() && part_count >= 2 && total >= std::size_t{1} << 17U) {
+ workers = std::min({par_.final_ordering.worker_cap, part_count, total / 4096});
+ }
+
+ if (workers < 2) {
+ merge_segment(std::vector(part_count, 0), part_end, 0);
+ return;
+ }
+ note_finalize_fanout();
+
+ const std::uint64_t hi = owned_rows_seen_ + 1;
+ const auto frontier = [&](std::size_t rank) {
+ std::uint64_t lo = 0;
+ std::uint64_t high = hi;
+ while (lo < high) {
+ const std::uint64_t mid = lo + ((high - lo) / 2);
+ std::size_t sum = 0;
+ for (std::size_t p = 0; p < part_count; ++p) {
+ const auto& fr = partitions[p].first_rows;
+ sum += static_cast(std::lower_bound(fr.begin(), fr.end(), mid) -
+ fr.begin());
+ }
+ if (sum < rank) {
+ lo = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+ std::vector off(part_count);
+ for (std::size_t p = 0; p < part_count; ++p) {
+ const auto& fr = partitions[p].first_rows;
+ off[p] = static_cast(std::lower_bound(fr.begin(), fr.end(), lo) -
+ fr.begin());
+ }
+ return off;
+ };
+
+ std::vector> bounds(workers + 1);
+ bounds.front().assign(part_count, 0);
+ bounds.back() = part_end;
+ for (std::size_t w = 1; w < workers; ++w) {
+ bounds[w] = frontier(w * total / workers);
+ }
+
+ auto batch = process_worker_pool().submit(workers, [&](std::size_t w) {
+ std::size_t g = 0;
+ for (std::size_t p = 0; p < part_count; ++p) {
+ g += bounds[w][p];
+ }
+ merge_segment(bounds[w], bounds[w + 1], g);
+ });
+ batch.wait();
+ }
+
+ /// Dispatch the deferred merge to whichever key the owned run filled. Only
+ /// one can be non-empty: the gate admits an owned run only before any group
+ /// exists, so a single operator commits to one key and keeps it.
+ void finalize_owned_active() {
+ if (owned_async_hot_mode_) {
+ finalize_owned_async_hot();
+ } else if (owned_ordered_run_mode_) {
+ finalize_owned_ordered_runs();
+ } else if (!owned_pair_partitions_.empty()) {
+ finalize_owned(
+ owned_pair_partitions_, [&](std::size_t n) { pair_order_.resize(n); },
+ [&](std::size_t g, const PairIntKey& key) {
+ pair_order_[g] = {static_cast(key.first),
+ static_cast(key.second)};
+ });
+ } else if (!owned_int_partitions_.empty()) {
+ finalize_owned(
+ owned_int_partitions_, [&](std::size_t n) { int_order_.resize(n); },
+ [&](std::size_t g, std::int64_t key) { int_order_[g] = key; });
+ }
+ }
+
+ /// Join the streaming hot-table tasks once, then let one worker own each
+ /// cold partition for its complete lifetime. There is no chunk-local
+ /// histogram, scatter, or accumulate barrier: chunks only publish tasks;
+ /// the two synchronization points here are whole-stream hot completion and
+ /// whole-stream cold completion.
+ void finalize_owned_async_hot() {
+ if (owned_finalized_) {
+ return;
+ }
+ owned_finalized_ = true;
+ if (!owned_async_group_.has_value()) {
+ invariant_violation("async hot aggregate join: task group already released");
+ }
+ try {
+ owned_async_group_->wait();
+ } catch (const std::exception& error) {
+ owned_async_error_ = "async hot aggregate join: " + std::string(error.what());
+ return;
+ } catch (...) {
+ owned_async_error_ = "async hot aggregate join: non-standard worker exception";
+ return;
+ }
+ for (const auto& job : owned_async_jobs_) {
+ if (job->error.has_value()) {
+ owned_async_error_ = *job->error;
+ return;
+ }
+ }
+
+ auto& partitions = owned_int_partitions_;
+ const std::size_t part_count = owned_async_part_count_;
+ if (part_count >= 2) {
+ note_finalize_fanout();
+ }
+ try {
+ auto batch = process_worker_pool().submit(part_count, [&](std::size_t p) {
+ auto& partition = partitions[p];
+ std::size_t records = 0;
+ for (const auto& job : owned_async_jobs_) {
+ records += job->records_by_partition[p].size();
+ }
+
+ // This reserve runs concurrently per owner. Unlike the failed
+ // calling-thread reserve experiment, it neither serializes the
+ // partitions nor guesses from total input rows; the exact
+ // pre-aggregate count is a safe upper bound on distinct keys.
+ partition.index.reserve(records);
+ partition.keys.reserve(records);
+ partition.first_rows.reserve(records);
+ partition.slots.reserve(records);
+
+ for (const auto& job : owned_async_jobs_) {
+ for (const auto& record : job->records_by_partition[p]) {
+ auto it = partition.index.find(record.key);
+ std::uint32_t local{};
+ if (it == partition.index.end()) {
+ local = static_cast(partition.keys.size());
+ partition.index.emplace(record.key, local);
+ partition.keys.push_back(record.key);
+ partition.first_rows.push_back(record.first_row);
+ partition.slots.push_back(record.slot);
+ } else {
+ local = it->second;
+ if (record.slot.present()) {
+ auto& slot = partition.slots[local];
+ slot.double_value += record.slot.double_value;
+ slot.mark_present();
+ }
+ }
+ }
+ }
+ });
+ batch.wait();
+ } catch (const std::exception& error) {
+ owned_async_error_ = "async cold aggregate: " + std::string(error.what());
+ return;
+ } catch (...) {
+ owned_async_error_ = "async cold aggregate: non-standard worker exception";
+ return;
+ }
+
+ // Pre-aggregate payloads are no longer needed. Release them before the
+ // output arrays are allocated so peak memory is cold state + output,
+ // rather than cold state + every streamed record + output.
+ owned_async_jobs_.clear();
+ owned_async_group_.reset();
+
+ // Reuse the already-parallel first-occurrence merge. `first_rows` in
+ // every cold partition is ascending because hot records are created at
+ // their first source row and jobs are consumed in input order.
+ owned_finalized_ = false;
+ finalize_owned(
+ partitions, [&](std::size_t n) { int_order_.resize(n); },
+ [&](std::size_t g, std::int64_t key) { int_order_[g] = key; });
+ }
+
+ /// A clustered single-Int64 Count is summarized as contiguous runs while
+ /// chunks arrive. If the complete stream is nondecreasing, adjacent runs
+ /// are the final groups and no hash table is needed at all. If a later
+ /// chunk disproves ordering, merge the run summaries through a hash map at
+ /// emission; this preserves exact first-occurrence semantics without
+ /// retaining or replaying the input rows.
+ void finalize_owned_ordered_runs() {
+ if (owned_finalized_) {
+ return;
+ }
+ owned_finalized_ = true;
+ if (owned_ordered_run_keys_.empty()) {
+ n_groups_ = 0;
+ return;
+ }
+
+ if (owned_ordered_runs_nondecreasing_) {
+ const bool ord_timing = std::getenv("IBEX_ORDERED_RUN_TIMING") != nullptr;
+ const auto ord_t0 = std::chrono::steady_clock::now();
+ const std::size_t run_count = owned_ordered_run_keys_.size();
+ const std::int64_t* const rk = owned_ordered_run_keys_.data();
+ const std::size_t* const rc = owned_ordered_run_counts_.data();
+
+ // How many workers can help. The build loop below is a segmented
+ // reduction over `run_count` sorted (key, count) runs: keys are
+ // nondecreasing so a group boundary is just `rk[i] != rk[i-1]`.
+ // Each worker owns a contiguous run slice; a key straddling a slice
+ // boundary has its leading partial count carried back to the group
+ // the previous worker finished (at most `workers - 1` fixups).
+ // Ceiling and permission from the plan; `run_count / 8192` and this
+ // path's own `1U << 16U` run floor stay here (the ordered-run merge
+ // is a strategy specialization, floor beside its code).
+ std::size_t workers = 1;
+ if (exec_ != nullptr && par_.final_ordering.decline == physical::FanOutDecline::None &&
+ !on_worker_pool_thread() && run_count >= (std::size_t{1} << 16U) &&
+ std::getenv("IBEX_DISABLE_PARALLEL_ORDERED_MERGE") == nullptr) {
+ workers = std::min({par_.final_ordering.worker_cap, run_count / 8192});
+ }
+
+ std::size_t total = 0;
+ if (workers < 2) {
+ total = run_count == 0 ? 0 : 1;
+ for (std::size_t i = 1; i < run_count; ++i) {
+ total += rk[i] == rk[i - 1] ? 0 : 1;
+ }
+ n_groups_ = total;
+ int_order_.resize(total);
+ AggSlotCore* slots = flat_slots_.grow_uninitialized(total).data();
+ if (total != 0) {
+ std::size_t group = 0;
+ int_order_[0] = rk[0];
+ slots[0] = AggSlotCore{};
+ slots[0].count = static_cast(rc[0]);
+ for (std::size_t i = 1; i < run_count; ++i) {
+ if (rk[i] != int_order_[group]) {
+ ++group;
+ int_order_[group] = rk[i];
+ slots[group] = AggSlotCore{};
+ }
+ slots[group].count += static_cast(rc[i]);
+ }
+ }
+ } else {
+ note_finalize_fanout();
+ auto& pool = process_worker_pool();
+ const std::size_t grain = (run_count + workers - 1) / workers;
+ std::vector local_groups(workers, 0);
+ std::vector boundary_open(workers, 0);
+ {
+ auto batch = pool.submit(workers, [&](std::size_t w) {
+ const std::size_t begin = w * grain;
+ const std::size_t end = std::min(run_count, begin + grain);
+ if (begin >= end) {
+ return;
+ }
+ std::size_t g = 0;
+ for (std::size_t i = begin; i < end; ++i) {
+ if (i == 0 || rk[i] != rk[i - 1]) {
+ ++g;
+ }
+ }
+ local_groups[w] = g;
+ boundary_open[w] = (begin > 0 && rk[begin] == rk[begin - 1]) ? 1 : 0;
+ });
+ batch.wait();
+ }
+ std::vector group_base(workers + 1, 0);
+ for (std::size_t w = 0; w < workers; ++w) {
+ group_base[w + 1] = group_base[w] + local_groups[w];
+ }
+ total = group_base[workers];
+ n_groups_ = total;
+ int_order_.resize(total);
+ AggSlotCore* slots = flat_slots_.grow_uninitialized(total).data();
+ std::vector carry(workers, 0);
+ {
+ auto batch = pool.submit(workers, [&](std::size_t w) {
+ const std::size_t begin = w * grain;
+ const std::size_t end = std::min(run_count, begin + grain);
+ if (begin >= end) {
+ return;
+ }
+ std::size_t i = begin;
+ if (boundary_open[w] != 0) {
+ const std::int64_t k0 = rk[begin];
+ std::int64_t c = 0;
+ while (i < end && rk[i] == k0) {
+ c += static_cast(rc[i]);
+ ++i;
+ }
+ carry[w] = c;
+ }
+ std::size_t g = group_base[w];
+ while (i < end) {
+ const std::int64_t k = rk[i];
+ std::int64_t c = 0;
+ while (i < end && rk[i] == k) {
+ c += static_cast(rc[i]);
+ ++i;
+ }
+ int_order_[g] = k;
+ slots[g] = AggSlotCore{};
+ slots[g].count = c;
+ ++g;
+ }
+ });
+ batch.wait();
+ }
+ for (std::size_t w = 0; w < workers; ++w) {
+ if (boundary_open[w] != 0) {
+ slots[group_base[w] - 1].count += carry[w];
+ }
+ }
+ }
+
+ if (ord_timing) {
+ const auto ms = std::chrono::duration(
+ std::chrono::steady_clock::now() - ord_t0)
+ .count();
+ ibex::formatting::print(stderr,
+ "[ord_run] finalize nondecreasing runs={} groups={} "
+ "workers={} {}ms\n",
+ run_count, total, workers, ms);
+ }
+ return;
+ }
+
+ robin_hood::unordered_flat_map index;
+ std::vector counts;
+ for (std::size_t i = 0; i < owned_ordered_run_keys_.size(); ++i) {
+ const std::int64_t key = owned_ordered_run_keys_[i];
+ auto it = index.find(key);
+ std::uint32_t group{};
+ if (it == index.end()) {
+ group = static_cast(int_order_.size());
+ index.emplace(key, group);
+ int_order_.push_back(key);
+ counts.push_back(0);
+ } else {
+ group = it->second;
+ }
+ counts[group] += static_cast(owned_ordered_run_counts_[i]);
+ }
+ n_groups_ = int_order_.size();
+ AggSlotCore* slots = flat_slots_.grow_uninitialized(n_groups_).data();
+ for (std::size_t group = 0; group < n_groups_; ++group) {
+ slots[group] = AggSlotCore{};
+ slots[group].count = counts[group];
+ }
+ }
+
+ // Two fixed-width-integer keys, grouped as one composite. Mirrors
+ // process_rows_int exactly, packing (key_a, key_b) into a two-word key so
+ // a single hash probe replaces the generic path's per-key Key comparison.
+ auto process_rows_int_pair(const std::vector& group_entries,
+ const std::vector& agg_entries, std::size_t rows)
+ -> std::optional {
+ // Bind the key column's buffer once, the way `process_rows_int` does.
+ // Reading it through `std::get` per row costs a variant index check per
+ // key per row and re-derives the pointer every time, which on 8M rows
+ // over two keys was most of this loop.
+ struct RawKeyReader {
+ const std::int64_t* i64 = nullptr;
+ const Date* dates = nullptr;
+ const Timestamp* stamps = nullptr;
+ const Column::code_type* codes = nullptr;
+ IntKeyKind kind = IntKeyKind::Int64;
+
+ [[nodiscard]] auto operator()(std::size_t row) const -> std::int64_t {
+ switch (kind) {
+ case IntKeyKind::Int64:
+ return i64[row];
+ case IntKeyKind::Date:
+ return dates[row].days;
+ case IntKeyKind::Ts:
+ return stamps[row].nanos;
+ case IntKeyKind::Cat:
+ return codes[row];
+ }
+ return 0;
+ }
+ };
+ const auto bind_reader = [](const ColumnValue& col, IntKeyKind kind) -> RawKeyReader {
+ RawKeyReader reader;
+ reader.kind = kind;
+ switch (kind) {
+ case IntKeyKind::Int64:
+ reader.i64 = std::get>(col).data();
+ break;
+ case IntKeyKind::Date:
+ reader.dates = std::get>(col).data();
+ break;
+ case IntKeyKind::Ts:
+ reader.stamps = std::get>(col).data();
+ break;
+ case IntKeyKind::Cat:
+ reader.codes = std::get>(col).codes_data();
+ break;
+ }
+ return reader;
+ };
+ const auto key_a_at = bind_reader(*group_entries[0]->column, int_key_kind_);
+ const auto key_b_at = bind_reader(*group_entries[1]->column, int_key_kind_b_);
+ const auto pack = [](std::int64_t a, std::int64_t b) -> PairIntKey {
+ return {.first = static_cast(a),
+ .second = static_cast(b)};
+ };
+
+ gids_buf_.resize(rows);
+ auto* gids = gids_buf_.data();
+
+ // A Categorical code and a Date are both 32 bits wide, so when both
+ // keys are one of those the composite fits in 64 bits exactly and can
+ // be probed in the same flat int map the single-int path uses. That is
+ // the common shape of `by { symbol, day }`, and it halves the key
+ // width, the hash and the stored entry against the 128-bit form.
+ // Both key domains are 32 bits wide here, so the composite is exact.
+ const auto pack_u64 = [](std::int64_t a, std::int64_t b) -> std::int64_t {
+ return static_cast(
+ (static_cast(static_cast(a)) << 32U) |
+ static_cast(static_cast(b)));
+ };
+ if (pair_packs_u64_) {
+ // Both key domains are narrow enough to enumerate: a Categorical
+ // spans its dictionary, and a Date column's span is measured. When
+ // the product fits, index a flat cell -> gid array and the per-row
+ // hash disappears entirely -- the same trick, and the same reason,
+ // as the all-Categorical Cartesian path. `by { day }` over 4
+ // distinct days was costing 34ms on 8M rows purely in hash probes.
+ if (try_process_rows_pair_dense(key_a_at, key_b_at, group_entries, agg_entries, rows)) {
+ return std::nullopt;
+ }
+ // Discovery across workers, for the case the dense array cannot
+ // hold: the cell budget is a product, so a wide symbol domain times
+ // a wide day domain overflows it long before either alone is
+ // remarkable, and the u64 key that falls out is the CHEAPEST key in
+ // this file to partition. Until now this branch returned before ever
+ // reaching `try_discover_partitioned` — a `by { symbol, day }` over
+ // 5000 symbols and 1000 days ran wholly serially.
+ //
+ // Only while the dense path has never run. Dense numbers groups in
+ // its own array and rebuilds that array from `pair_order_`, so it
+ // can safely take over from partitioned discovery; the reverse is
+ // not true, because the partitions would not know the groups dense
+ // had already numbered and would issue second ids for them.
+ if (!pair_dense_active_ &&
+ try_discover_partitioned>(
+ [&](std::size_t row) { return pack_u64(key_a_at(row), key_b_at(row)); }, rows,
+ gids, int_partitions_, [&](std::size_t n) { pair_order_.resize(n); },
+ [&](std::int64_t, std::uint32_t gid, std::size_t row) {
+ // From the row, not by unpacking: `pair_order_` holds the
+ // reader's own values, and the pack truncates to 32 bits.
+ pair_order_[gid] = {key_a_at(row), key_b_at(row)};
+ },
+ kDefaultPartitionMinRows,
+ [&](std::uint32_t gid) {
+ // The pack is a pure function of the pair, so a group's
+ // key is recoverable even though the pack is lossy.
+ return pack_u64(pair_order_[gid].first, pair_order_[gid].second);
+ })) {
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ // Falling here with groups already numbered means the dense path ran
+ // on an earlier chunk and this chunk's domains overflowed its budget.
+ // `int_index_` has no record of those groups, so without this it
+ // would issue a second id for each and the output would carry two
+ // rows per key. `pair_order_` is the pair path's source of truth —
+ // this is the same rebuild dense itself does when its bounds move.
+ if (int_index_.size() < pair_order_.size()) {
+ int_index_.reserve(pair_order_.size());
+ for (std::size_t g = 0; g < pair_order_.size(); ++g) {
+ int_index_.emplace(pack_u64(pair_order_[g].first, pair_order_[g].second),
+ static_cast(g));
+ }
+ }
+
+ std::int64_t prev_packed = 0;
+ std::uint32_t prev_gid_u64 = std::numeric_limits::max();
+ bool have_prev_u64 = false;
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::int64_t a = key_a_at(row);
+ const std::int64_t b = key_b_at(row);
+ const std::int64_t key = pack_u64(a, b);
+ std::uint32_t gid{};
+ if (have_prev_u64 && key == prev_packed) {
+ gid = prev_gid_u64;
+ } else {
+ auto it = int_index_.find(key);
+ if (it == int_index_.end()) {
+ gid = static_cast(n_groups_);
+ int_index_.emplace(key, gid);
+ pair_order_.emplace_back(a, b);
+ ++n_groups_;
+ size_group_arrays();
+ } else {
+ gid = it->second;
+ }
+ prev_packed = key;
+ prev_gid_u64 = gid;
+ have_prev_u64 = true;
+ }
+ gids[row] = gid;
+ }
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ if (try_owned(
+ [&](std::size_t row) { return pack(key_a_at(row), key_b_at(row)); }, rows, gids,
+ agg_entries, owned_pair_partitions_, kPairOwnedMinRows)) {
+ publish_fused_accumulation();
+ return std::nullopt;
+ }
+
+ if (try_discover_partitioned(
+ [&](std::size_t row) { return pack(key_a_at(row), key_b_at(row)); }, rows, gids,
+ pair_partitions_, [&](std::size_t n) { pair_order_.resize(n); },
+ [&](const PairIntKey& key, std::uint32_t gid, std::size_t) {
+ pair_order_[gid] = {static_cast(key.first),
+ static_cast(key.second)};
+ },
+ kDefaultPartitionMinRows,
+ [&](std::uint32_t gid) {
+ return pack(pair_order_[gid].first, pair_order_[gid].second);
+ })) {
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ PairIntKey prev_key{};
+ std::uint32_t prev_gid = std::numeric_limits::max();
+ bool have_prev = false;
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::int64_t a = key_a_at(row);
+ const std::int64_t b = key_b_at(row);
+ const PairIntKey key = pack(a, b);
+ std::uint32_t gid{};
+ if (have_prev && key == prev_key) {
+ gid = prev_gid;
+ } else {
+ auto it = pair_index_.find(key);
+ if (it == pair_index_.end()) {
+ gid = static_cast(n_groups_);
+ pair_index_.emplace(key, gid);
+ pair_order_.emplace_back(a, b);
+ ++n_groups_;
+ size_group_arrays();
+ } else {
+ gid = it->second;
+ }
+ prev_key = key;
+ prev_gid = gid;
+ have_prev = true;
+ }
+ gids[row] = gid;
+ }
+
+ publish_discovered(agg_entries, rows);
+ return std::nullopt;
+ }
+
+ /// Bounds of one key column over a chunk, as the dense cell numbering needs
+ /// them. A Categorical answers from its dictionary without reading a row;
+ /// anything else is measured.
+ template
+ auto key_bounds(const Reader& read, const ColumnValue& col, IntKeyKind kind, std::size_t rows)
+ -> std::pair {
+ if (kind == IntKeyKind::Cat) {
+ const auto size = std::get>(col).dictionary().size();
+ return {0, size == 0 ? 0 : static_cast(size) - 1};
+ }
+ std::int64_t lo = read(0);
+ std::int64_t hi = lo;
+ for (std::size_t row = 1; row < rows; ++row) {
+ const std::int64_t v = read(row);
+ lo = std::min(lo, v);
+ hi = std::max(hi, v);
+ }
+ return {lo, hi};
+ }
+
+ /// Group a packed 32-bit key pair through a flat cell array. Returns false
+ /// when the key domains are too large to enumerate, leaving the caller on
+ /// the hash path.
+ ///
+ /// The cell numbering is a function of the bounds, so a later chunk that
+ /// widens them invalidates every cell already handed out. That is handled
+ /// the way the multi-key Categorical path handles a stride change: widen to
+ /// the union and rebuild the array from `pair_order_`, which holds each
+ /// group's key pair. Group ids themselves never move.
+ template
+ auto try_process_rows_pair_dense(const ReaderA& key_a_at, const ReaderB& key_b_at,
+ // One caller
+ const std::vector& group_entries,
+ const std::vector& agg_entries,
+ std::size_t rows) -> bool {
+ if (rows == 0) {
+ return false;
+ }
+ const auto [a_lo, a_hi] =
+ key_bounds(key_a_at, *group_entries[0]->column, int_key_kind_, rows);
+ const auto [b_lo, b_hi] =
+ key_bounds(key_b_at, *group_entries[1]->column, int_key_kind_b_, rows);
+
+ std::int64_t a_min = a_lo;
+ std::int64_t b_min = b_lo;
+ std::int64_t a_max = a_hi;
+ std::int64_t b_max = b_hi;
+ if (pair_dense_active_) {
+ a_min = std::min(a_min, pair_dense_a_min_);
+ b_min = std::min(b_min, pair_dense_b_min_);
+ a_max = std::max(a_max, pair_dense_a_max_);
+ b_max = std::max(b_max, pair_dense_b_max_);
+ }
+
+ // Spans are computed in unsigned arithmetic so a domain that legitimately
+ // straddles zero cannot overflow the subtraction.
+ const auto a_span = static_cast(a_max - a_min) + 1;
+ const auto b_span = static_cast(b_max - b_min) + 1;
+ if (b_span != 0 && a_span > kDenseCellLimit / b_span) {
+ return false; // product overflows the dense budget
+ }
+ const std::uint64_t cells = a_span * b_span;
+ if (cells > kDenseCellLimit) {
+ return false;
+ }
+
+ const bool bounds_changed = !pair_dense_active_ || a_min != pair_dense_a_min_ ||
+ b_min != pair_dense_b_min_ || b_span != pair_dense_b_span_;
+ if (bounds_changed) {
+ pair_dense_gid_.assign(static_cast(cells), kNoGid);
+ for (std::size_t g = 0; g < pair_order_.size(); ++g) {
+ const auto cell =
+ (static_cast(pair_order_[g].first - a_min) * b_span) +
+ static_cast(pair_order_[g].second - b_min);
+ pair_dense_gid_[static_cast(cell)] = static_cast(g);
+ }
+ pair_dense_a_min_ = a_min;
+ pair_dense_b_min_ = b_min;
+ pair_dense_a_max_ = a_max;
+ pair_dense_b_max_ = b_max;
+ pair_dense_b_span_ = b_span;
+ pair_dense_active_ = true;
+ } else if (pair_dense_gid_.size() < cells) {
+ pair_dense_gid_.resize(static_cast(cells), kNoGid);
+ pair_dense_a_max_ = a_max;
+ }
+
+ gids_buf_.resize(rows);
+ auto* gids = gids_buf_.data();
+ std::uint32_t* dense = pair_dense_gid_.data();
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::int64_t a = key_a_at(row);
+ const std::int64_t b = key_b_at(row);
+ const auto cell = (static_cast(a - a_min) * b_span) +
+ static_cast(b - b_min);
+ std::uint32_t gid = dense[cell];
+ if (gid == kNoGid) {
+ gid = static_cast(n_groups_);
+ dense[cell] = gid;
+ pair_order_.emplace_back(a, b);
+ ++n_groups_;
+ size_group_arrays();
+ dense = pair_dense_gid_.data();
+ }
+ gids[row] = gid;
+ }
+
+ publish_discovered(agg_entries, rows);
+ return true;
+ }
+
+ /// Slot-indexed boxed value, grown on first use. `slot_index` is the same
+ /// `gid * n_aggs_ + agg_i` that indexes `flat_slots_`.
+ auto text_at(std::size_t slot_index) -> ScalarValue& {
+ if (text_store_.size() < flat_slots_.size()) {
+ text_store_.resize(flat_slots_.size());
+ }
+ return text_store_[slot_index];
+ }
+
+ /// Scratch for one (group, aggregate). Only valid when that aggregate
+ /// declared scratch_doubles > 0.
+ [[nodiscard]] auto scratch_for(std::size_t gid, std::size_t agg_i) -> double* {
+ return scratch_.data() + (gid * scratch_stride_) + scratch_offset_[agg_i];
+ }
+
+ /// Size every per-group array to `n_groups_`. THE ONLY PLACE THAT RESIZES
+ /// THEM — the grouping fast paths used to call `flat_slots_.resize()`
+ /// directly, and adding a second per-group array (scratch) to just one of
+ /// those call sites left the others reading a null pointer.
+ /// One hash partition's share of the group index. Partitions are disjoint by
+ /// construction — a key's partition is a function of its hash — so a worker
+ /// owning a partition owns every row and every group in it, and needs no
+ /// lock and no merge against the others.
+ /// `Eq` is spelled out so a transparent hash/equal pair can be used: the
+ /// string path stores owning `std::string` keys but probes with
+ /// `std::string_view`, and only pays the copy on a genuinely new group —
+ /// exactly what the serial `str_index_` does.
+ template