Skip to content

feat(pass2): share SQL table scans; a coarser grouping reads a finer grouping's pane chain, newest panes rolled up (#878 part 2) - #883

Closed
zzylol wants to merge 2 commits into
mainfrom
fix/878-share-sql-scan-and-rollup-panes
Closed

zzylol wants to merge 2 commits into
mainfrom
fix/878-share-sql-scan-and-rollup-panes

Conversation

@zzylol

@zzylol zzylol commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Why

Issue #878, problem 2. Q1 (the overall p50 and p90 over 5 minutes) and Q2 (the p90 per city over 20 minutes) run every 10 s over one SQL table, yet no candidate shared anything between them, not even the scan. Q2's per-city chain of 120 panes of 10 s holds everything Q1 needs: Q1 could merge the newest 30 panes of each city and then roll the cities up into one state.

Diagnosis (the three suspected causes)

  • (a) Scans were not widened for SQL (feat(pass2): share scans of one metric that project different labels #868): refuted. A SQL scan reads every column of the catalog table, so Q1's and Q2's scans are already equal. The real cause is the legality gate in structural CSE (crates/types/src/ir/cse.rs, InternTable::intern): a non-ASAP node is shared only when its rows have a unique key, and a table's rows have none. So sharing_candidates never offered shared Scan (Q1, Q2).
  • (b) Newest-n and rollup don't combine: partly confirmed. One SummaryMerge with both newest_panes and group_by is rejected (ASAPOp::validate_inputs). Two merges stacked (a rollup over a newest-panes merge) were already valid IR (rolled_up_build and SummaryCoverage::derive read a merged chain). The real gaps were these:
    • No rule built the stack. The window-composition rule shares chains only between builds with the same grouping (same_build_over_one_input), and the sketch rollup rule only between builds over the same rows, so the same window (finer_keys / same_rows).
    • A rollup to no keys over a SQL relation was rejected (CoverageError::GlobalRelationRegroup, and in finer_keys). The executor's merge outputs no row when it has no input state, but a global SQL aggregate has one row.
  • (c) Sizing for the strictest reader: not a problem. The shared chain is sized with sized_for_all over every member. KLL's rank error and DDSketch's relative error do not depend on q, so one sketch serves Q1's p50 and p90. Here every reader has ε = 0.01, and the chain is a KLL with k = 345, the size each query has alone.

What

  • Equal scans are shared even when their rows have no unique key, so the identical-expression rule offers shared Scan (Q1, Q2).

  • The window-composition rule offers one more shared chain per subset of pane candidates. A sketch build over a SQL table's rows, grouped by fewer keys, reads the chain of a finer grouping when:

    • the finer build has the same family (at any size), input, grouping and filter over the same rows;
    • the finer build's window is at least as long, and its pane width divides the coarser build's;
    • every reader of the coarser build is an estimate whose merged readout keeps a guarantee (as for the rollup rule).

    The coarser build reads the chain as a rollup (SummaryMerge { group_by }) over the merge of its newest panes (SummaryMerge { newest_panes }). The chain is sized for every reader. PromQL builds and exact accumulators are not rolled up this way yet; this PR has no end-to-end test for them.

  • A rollup to no keys of a SQL relation's states stands for the global SQL aggregate. When there is no input state, the executor outputs one empty state, so the answer is one NULL row, as the global build gives. The coverage check no longer rejects it, and the rollup rule offers it too. An exact global COUNT(*) is still never answered from grouped counts: the exact rollup never sums Int64 counts again, so it can't turn 0 into NULL.

  • Candidate labels name the readers of such a chain: one Kll for Q1, Q2 in 120 × 10s panes (Q1 newest 30).

How

  • cse.rs: intern treats a Scan as reusable.
  • asap_aware_cse.rs:
    • rollable_pane_candidates (computed once per rule call) marks the sketch builds over SQL rows whose readers keep a merged guarantee. rollup_window_groups(chosen, rollable) picks hosts by most group keys, then longest window. Each pane candidate joins one group (rolls_up_into). Only groups with a coarser member are listed; the rest are nested_window_groups' groups.
    • window_composition_pass2_candidates pushes these groups through the existing push_shared_chains.
    • shared_chain_build takes, among the longest windows, the finest grouping as the host.
    • in_panes wraps a coarser member's newest-panes merge in regrouped(member, merge).
    • The reader check is factored out of sketch_rollup_candidates into read_with_merged_guarantee.
  • Executor: SummaryMerge gets an emit_empty_global flag (named after the build's own). compile_internal sets it for a group_by: Some([]) merge whose states were built from a relation's rows (built_from_relation), and merge_summary then adds one empty group when there is no input state.

Key code interfaces added / modified

crates/executor/src/operators/mod.rs, crates/executor/src/operators/summary/mod.rs

/// The executor operator kinds (type stays).
enum Kind {
    // ... every other variant unchanged
    /// Merge summary states into one per group.
    SummaryMerge {
        /// The state column.
        state: usize, // unchanged
        /// The group columns.
        groups: Vec<usize>, // unchanged
        /// Whether a merge of no input state outputs one empty state, as a global SQL
        /// aggregate has one row over no input rows. Only with no groups; default false.
        emit_empty_global: bool, // new
    },
}

impl Operator {
    /// A merge into no groups that outputs one empty state when it has no input state.
    /// The physical planner uses it for a rollup of a relation's states to no keys.
    /// Errors (bad input) on any other operator.
    pub fn with_emit_empty_global(self) -> Result<Self, Error>; // new
}

crates/types/src/ir/properties/summary_coverage.rs

/// Why a summary node has no coverage (type stays).
pub enum CoverageError {
    // ... every other variant unchanged
    /// A rollup to no keys over a SQL relation's states. Removed: it now stands for the
    /// global SQL aggregate.
    GlobalRelationRegroup, // removed
}

The new Pass 2 helpers (rollable_pane_candidates, rollup_window_groups, rolls_up_into, group_key_count, read_with_merged_guarantee) are private to asap_aware_cse.rs.

Usage (end to end, the issue's workload):

# wl.json (see #878): Q1 = percentile_cont 0.5 and 0.9 over the last 5 minutes; Q2 = percentile_cont 0.9
# per city over the last 20 minutes; both every 10 s at ε = 0.01, δ = 0.001, over metrics(ts, city, value).
cargo run -p asap-devtools --bin stage_pipeline -- --workload wl.json --exhaustive --max-displayed 2000 --out out.json \
  --table '{"name": "metrics", "columns": [{"name": "ts", "type": "timestamp", "nullable": false}, {"name": "city", "type": "utf8"}, {"name": "value", "type": "float64"}], "time_index": 0}'
# Pass 1 composes "Q1 Kll + Kll · Q2 Kll". Pass 2's window-composition rule finds the pane candidates
# Q1 (30 × 10 s, global) and Q2 (120 × 10 s, per city). rollup_window_groups makes Q2 the host and Q1 a coarser member,
# and in_panes builds:
#   PaneChain(Kll k=345, by city, 120 × 10 s)            <- sized for Q1's and Q2's ε (sized_for_all)
#     ├─ SummaryMerge (all 120 panes) → SummaryEstimate p90 → Q2
#     └─ SummaryMerge { newest_panes: 30 } → SummaryMerge { group_by: [] } → SummaryEstimate p50, p90 → Q1
# Stage 2 keeps the chain in memory (MatAtQuery), and Stage 3 selects it:
#   selected P125-m2 "Q1 Kll + Kll · Q2 Kll · one Kll for Q1, Q2 in 120 × 10s panes (Q1 newest 30) · shared SummaryMerge (Q1), SummaryMerge (Q1)"
#   0.4989 cost/s; every other candidate rejected as costlier.

Major algorithm changes

Problem, with a concrete input. Two queries over metrics(ts, city, value) run every 10 s at ε = 0.01:

  • Q1 = percentile_cont 0.5 and 0.9 over the last 5 minutes;
  • Q2 = percentile_cont 0.9 GROUP BY city over the last 20 minutes.

Each query's plan is Scan(metrics) → TimeRange(W) → SummaryAgg(Kll). Q1's build is global; Q2's is per city, in 30 and 120 panes of 10 s. Before this PR, the two plans shared no node.

1. SQL scan sharing (structural CSE, asap_types::ir::cse)

Before:

  1. share_common_sub_dags interns bottom-up. InternTable::intern(node) returns an existing equal node only if node.is_asap() || node.schema.has_unique_key().
  2. A table scan's rows have no unique key, so Q1's and Q2's equal Scan(metrics) stay two nodes. sharing_candidates finds nothing, and the identical-expression rule offers no shared Scan (Q1, Q2).

After:

  1. intern also reuses an equal node when it is a NonASAPOp::Scan.
  2. sharing_candidates now lists the two scans, and identical_expression_pass2_candidates offers the candidate with them shared, next to the one without. Stage 3 picks between them.

2. Newest-n plus rollup composition (window-composition rule, asap_aware_cse)

Before, window_composition_pass2_candidates(roots, candidates) did this for each non-empty subset chosen of pane candidates:

  1. Every build in its own chain: in_panes(roots, chosen, []).
  2. Shared chains from nested_window_groups(chosen) and from gcd_window_groups(chosen). Both admit a member only if same_build_over_one_input holds, which requires the same reduction. So Q1 (global) and Q2 (by city) never shared a chain.

After, the same loop also does:

rollable = rollable_pane_candidates(roots, candidates)        // once per rule call
for each subset `chosen`:
    ... steps 1–2 as before ...
    groups = rollup_window_groups(chosen, rollable[chosen])
      hosts in order (most group keys, longest window, position)
      for host h not yet grouped:
        members = {h} ∪ {i not grouped |
                     window(i) ≤ window(h)
                     ∧ pane_width(h) divides pane_width(i)
                     ∧ rolls_up_into(i, h)            // same family/input/grouping/filter over the same rows;
                                                      // keys(i) ⊆ keys(h); merges_groups(family)
                     ∧ (keys(i) = keys(h) ∨ rollable[i])}  // sketch over SQL rows, readers keep a merged guarantee
        keep the group only if some member is coarser than h
    push_shared_chains(roots, chosen, groups)
      shared_chain_build: host = the longest window, then the most keys
                          → sized_for_all(host, members…)  (the strictest reader's size)
      in_panes, for each member i:
        merge_i = SummaryMerge { newest_panes: window(i) / pane_width, [chain] }
        read_i  = keys(i) = keys(host) ? merge_i
                                       : regrouped(build_i, merge_i)   // SummaryMerge { group_by: keys(i) }

For the issue's workload this gives:

  • PaneChain(Kll k = 345, by city, 120 × 10 s);
  • Q2 reads SummaryMerge(all panes);
  • Q1 reads SummaryMerge { group_by: [] } over SummaryMerge { newest_panes: 30 }, and both of its estimates read it.

3. Global SQL rollup in the executor

  • SummaryCoverage no longer rejects a rollup to no keys over a relation (GlobalRelationRegroup is removed), and finer_keys no longer refuses it.
  • compile_internal sets with_emit_empty_global on a group_by: Some([]) merge whose states built_from_relation.
  • merge_summary then outputs one empty state when it has no input state.

Correctness argument and tests

  • Shared scan. A scan is a pure read of the source at one evaluation. Equal scans (same source, predicates and schema) output the same rows, and every reader reads all of them. Time windows are TimeRange nodes above the scan, so readers of different windows stay separate. Test: q1_and_q2_share_the_table_scan; cse::tests::equal_scans_without_a_unique_key_are_shared.
  • Newest-n. Every window ends at the evaluation time. On a shared pane grid, a member's window is exactly the chain's newest W_i / w panes. The merge of those panes equals one build over W_i; this is the already-proven merged_window_build equivalence.
  • Rollup. A finer grouping's groups partition its rows. Merging the states whose keys agree on the coarse keys gives the state of the coarse build over the same rows. For a mergeable sketch, the family's merged error applies: KLL keeps its rank bound, and DDSketch's bucket counts add. SummaryCoverage::derive checks the stack (rollup over newest panes) against the coarse build's definition.
  • Sizing. The chain is sized with the per-parameter maximum over every member (sized_for_all), so each reader gets at least its own accuracy. KLL and DDSketch error does not depend on q, so one state answers both p50 and p90.
  • Empty window. A global SQL aggregate has one row over no rows. The rollup now emits one empty state, which the estimate reads as NULL, as the direct global build does.

Tests in q2_pane_chain_serves_q1_through_newest_panes_and_a_rollup (KLL and DDSketch, the issue's queries, NULL cities included), run on the executor with pane chains expanded:

  • Q1's two percentiles and Q2's four per-city percentiles are within the sketch's bound of the exact percentile_cont;
  • Q1 answers one NULL row over an empty 5-minute window.

a_global_count_is_not_rolled_up_from_grouped_counts checks that an exact COUNT(*) is never rolled up into a NULL. The guard is the existing Int64 refusal in rolled_up.

Complexity and candidate count

  • Grouping cost. rollable_pane_candidates is one DAG walk per rule call. rollup_window_groups is O(n²) per subset over n ≤ MAX_PANE_CANDIDATES = 20 pane candidates. A subset gains at most one Pass 2 candidate.
  • Interning. Scan sharing adds no cost to interning.
  • Candidates on this workload:
logical physical best cost/s
before 155 413 0.5475
scan sharing only 310 981 0.5377
this PR 348 1151 0.4989
  • Why the counts grow. Scan sharing doubles the candidates per shared table, because each candidate is listed with and without the shared scan; k shared tables give up to 2^k. The rollup chains add 38 logical and 170 physical candidates.

Limits

  • Sketches over SQL rows only. PromQL builds (quantile by (job) from by (job, instance) panes) and exact accumulators are not rolled up from a chain yet.
  • One host per member. Each pane candidate joins one rollup group. A coarser member reads the host with the most keys, even when another host would be cheaper.
  • Duplicate candidates with the rollup rule. A coarser member with the host's own window and panes duplicates what the rollup rule followed by window composition already gives. The FlatDag dedup removes the duplicate, but only after the candidate is built.
  • Scan sharing is a choice. A shared scan is offered as a choice, not always applied, so it doubles the search for each shared table.

Before this PR

On the issue's workload, after #881: 155 logical and 413 physical candidates. No candidate shares a node between Q1 and Q2. Stage 3 selects P49-m1 (Q1 Kll + Kll · Q2 Kll · Kll for Q1 in 30 × 10s panes · shared PaneChain (Q1), SummaryMerge (Q1)) at 0.5475 cost/s.

After this PR

logical physical best cost/s
before 155 413 0.5475 (Q1 in its own 30 panes)
scan sharing only 310 981 0.5377 (shared Scan (Q1, Q2), SummaryAgg (Q1))
this PR (scan sharing + rollup chains) 348 1151 0.4989 (Q2's chain serves Q1, newest 30 rolled up)

The selected plan is 8.9% cheaper.

Search size. Scan sharing doubles this workload's candidates: every candidate also comes with its scan shared, as the identical-expression rule lists both. The rollup chains add 38 logical and 170 physical candidates. Always sharing equal scans, instead of listing each candidate with and without, would undo that doubling. It is left as a question, not done here.

Verification

Integration tests, sql_two_percentiles (about 2 s; Stage 1 only for these three):

  • a_global_count_is_not_rolled_up_from_grouped_counts: with a rollup to no keys allowed over a table, no candidate answers a global COUNT(*) from per-city counts.
  • Failing first: q1_and_q2_share_the_table_scan. Some Stage 1 candidate reads one scan in both queries.
  • Failing first: q2_pane_chain_serves_q1_through_newest_panes_and_a_rollup. For KLL and DDSketch, there is a candidate in which both of Q1's estimates read a rollup to no keys over the newest 30 panes of Q2's 120-pane chain. The test runs that candidate on 2,400 rows over 20 minutes, with a NULL city on every seventh row, and with each pane chain expanded into its panes:
    • Q1's p50 and p90, and Q2's p90 for each of the three cities and the NULL city, are within the sketch's bound of the exact percentile_cont.
    • Over a table with no row in the last 5 minutes, Q1 answers one NULL row. This check fails without the executor's emit_empty_global, which gives no row.

Unit and other tests:

  • cse::tests::equal_scans_without_a_unique_key_are_shared (types).
  • summary_coverage::a_rollup_to_no_keys_of_a_relation_covers_the_global_build (types; replaces the rejection test). sketch_rollup::invalid_rollups_are_rejected now expects a rollup to no keys to be valid.
  • On a 56-core server, cargo test --no-fail-fast -p asap-types -p asap-executor -p asap-logical-asap-optimization -p asap-integration-tests -p asap-devtools -p asap-plan-selection -p asap-planner --all-features: every test binary passes, including the committed dag-viewer examples check, joint_search_equivalence, sketch_rollup*, sql_trailing_window and tumbling_pane_merge.
  • cargo fmt --all --check and cargo clippy -p asap-types -p asap-executor -p asap-logical-asap-optimization -p asap-devtools -p asap-integration-tests --all-targets --all-features -- -D warnings: clean.

Docs: planner-layering.md (SQL scan sharing; a coarser grouping reading a finer grouping's chain) and asap-primitive-schema.md (a rollup to no keys over a relation, CoverageError).

Closes #878.

…grouping's pane chain, newest panes rolled up

Two queries over one SQL table shared nothing: structural CSE shares a
non-ASAP node only when its rows have a unique key, and a table scan's
rows have none. Equal scans are now shared: each reader reads every row
the scan outputs.

The window-composition rule now also groups a build with a finer
grouping's build over a window at least its own: one chain of the finer
grouping's panes, sized for every reader, which the coarser build reads
as its newest panes rolled up into its own groups. A global SQL percentile
over 5 minutes reads the newest 30 of 120 per-city panes kept for a
per-city percentile over 20 minutes.

A rollup to no keys of a SQL relation's states now stands for the global
SQL aggregate: the executor outputs one empty state when it has no input
state, so the answer is one NULL row, as the global build gives. The
coverage check no longer rejects it, and the rollup rule offers it.

Part of #878 (problem 2).
@zzylol zzylol added CSE enhancement New feature or request optimizer Workload optimization and plan selection rollup Fine-to-coarse aggregation and summary reuse sql SQL front-end lowering (DataFusion → L2) window Window functions, frames, panes, or temporal semantics labels Oct 22, 2026
…ion (a count of no rows is 0, a sum of no counts NULL)
@zzylol zzylol closed this Oct 22, 2026
@zzylol

zzylol commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #887: the head of this PR stopped syncing after a force-push and the PR could not be reopened. #887 has the same branch, the review fixes and the description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CSE enhancement New feature or request optimizer Workload optimization and plan selection rollup Fine-to-coarse aggregation and summary reuse sql SQL front-end lowering (DataFusion → L2) window Window functions, frames, panes, or temporal semantics

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQL: mixed exact/sketch plans for a two-percentile query fail to compile; two queries over one table share nothing

1 participant