Skip to content

fix(plan-selection): evaluate the queries' separate plans first in the seed round - #973

Merged
zzylol merged 5 commits into
mainfrom
fix/seed-independent-plan
Oct 28, 2026
Merged

zzylol merged 5 commits into
mainfrom
fix/seed-independent-plan

Conversation

@zzylol

@zzylol zzylol commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Why

The joint search can return a plan costlier than planning each query on its own:

  • Disjoint candidates: two per-city p90 queries cost 0.0524189/s each when planned alone (an ingestion-time KLL pane chain each), so 0.1048378/s together. Planned together, main selects 0.2084510/s: both queries scan raw rows at query time.
  • Shared scan: a p90 and a MAX over the same 5-minute window of metrics cost 0.0578418/s planned separately; main selects 0.1614550/s for the two together (2.79×). A p90 plus a per-city COUNT(*): 0.0796515/s separately, 0.1762188/s together (2.21×).

The cause is in the first round of the candidate-connection search. The MILP proposes plans in order of their lower bound, and costing/lower_bounds.rs prices query-time scans, windows, aggregates and sketch builds at 0, while an ingestion-time scan has a positive bound. So every query-time combination ranks before the cheaper ingestion plans, and the round's cap (min(total/4, 5 s) and 32 evaluations in the seed-grid round) can end the search there.

What

In the first round of a search, each query is planned alone, and the combination of those plans is evaluated for the whole workload after the joint search, when the joint search ended above the queries' summed cost. The selected plan then costs no more than that combination.

Workload (SQL, every 10 s, 5-minute window) Main This PR Separate plans
p90 for city = 'NYC' and p90 for city = 'SF' 0.2084510/s 0.1048378/s 0.1048378/s
p90 and MAX(value) (one shared scan) 0.1614550/s 0.0578418/s 0.0578418/s
p90 and city, COUNT(*) … GROUP BY city (one shared scan) 0.1762188/s 0.0796515/s 0.0796515/s
p90 grouped by city and p90 for city IN ('NYC', 'SF') 0.2284651/s 0.2284651/s 0.2284651/s

On the finer-aggregate reuse branch (#952), whose reuse candidates make example C's queries share groups, see Verification.

How

  1. optimize_configured_candidates, in the first round of a multi-query search (CandidatePlanningContext::seed_separate_plans), calls separate_plans_combined with a quarter of the round's time before the joint search.

  2. separate_plans_combined, for each query:

    • searches it alone (optimize_configured_candidates over a projected graph, evaluated for that query alone) over the candidates no other query can reach;
    • when those hold no plan (the queries share a group, such as one scan), searches it over every candidate it can reach, if there are at most 128 (MAX_SHARED_SEARCH_CANDIDATES); larger graphs only take the joint search's time;
    • combines the queries' plans into one selection: a candidate two queries both select appears once, and the modeled cost counts each candidate once.

    It returns the combination with the sum of the queries' own costs, or None if any query finds no plan or the combination is not a valid selection.

  3. The joint search runs unchanged, with seven eighths of the remaining time when there is a combination. Afterwards, if it did not prove optimality and its incumbent costs more than the queries' summed cost, the combination is evaluated by the same evaluator (Stage 2 + PlanSelector for the whole workload) and becomes the incumbent if cheaper. It never takes the place of one of the search's own evaluations.

  4. The first round is the seed-grid round, or the single complete round of a workload without a pane window (route.rs: no earlier round in that search).

  5. project accepts a candidate set that is not closed under producers: it drops producers and root candidates outside the set, and a candidate whose required copy is outside the set gets AllowedOutputs { outputs: [] }, so it can never be selected. Components from components() are closed (a debug assertion checks it), so their projection is unchanged.

Key code interfaces added / modified

crates/plan-selection/src/search/refinement/components.rs

/// The largest graph of shared candidates a query is searched over alone.
const MAX_SHARED_SEARCH_CANDIDATES: usize = 128; // new

/// Each query's own plan, searched for that query alone, combined into one
/// selection of the whole graph, with the sum of the queries' own costs.
pub(in crate::search) fn separate_plans_combined(
    configured: &CandidateConfigurationInventory,
    context: &CandidatePlanningContext<'_>,
    limits: CandidateSearchLimits,
) -> Option<(SelectedCandidateGraph, f64)>; // new

crates/plan-selection/src/search/refinement/planner.rs

pub(in crate::search) struct CandidatePlanningContext<'a> {
    // …
    /// Whether the search first evaluates the queries' separate plans
    /// combined: in the first round of a search only.
    pub seed_separate_plans: bool, // new; set by route.rs, passed through to independent components, false elsewhere
}

The search loop (refinement/search.rs) is unchanged from main. All are crate-internal; no public API changes.

Usage, end to end:

  1. Input: a SQL workload with SELECT percentile_cont(0.9) WITHIN GROUP (ORDER BY value) FROM metrics WHERE ts > now() - INTERVAL '5' MINUTE and SELECT MAX(value) FROM metrics WHERE ts > now() - INTERVAL '5' MINUTE, each every 10 s (fixture 902.json: metrics has 666.67 rows/s, 100 series).
  2. Stage 1 → 3: run_stage1_to_stage3_with → candidate_connection_search → seed-grid round → search_domain (first round: seed_separate_plans = true) → optimize_configured_candidates.
  3. Separate plans: both queries read one scan, so neither has a plan of its own candidates (18 and 9). Searched over the 29 and 20 candidates they reach, they prove 0.0524189/s and 0.0054229/s. The combination selects the shared scan once.
  4. Joint search: stops at 0.1614550/s. That is above 0.0578418/s, so the combination is evaluated for the whole workload, 0.0578418/s, and replaces the incumbent.
  5. Output: 0.0578418/s instead of 0.1614550/s (percentile_and_max_over_one_scan_cost_no_more_than_separate_plans).

Major algorithm changes

Evaluate the queries' separate plans when the joint search ends above them

  • Problem: lower bounds of 0 for query-time work make the MILP try every query-time combination before ingestion-time plans, and the round's cap can end the search there, up to 2.8× the cost of the separate plans.
  • Before: direct-price phase, structural baseline, MILP proposals in order of lower bound, each evaluated and cut.
  • After: the same search, with a quarter of the first round's time spent beforehand on the per-query searches and an eighth of the rest kept for one more evaluation afterwards: the separate plans combined, only when the search ended above their summed cost.
  • Correctness: the combination is a valid selection of the same graph (validate_selected_graph), evaluated by the same whole-workload evaluator, and kept only if cheaper, so no unvalidated plan is selected and the certified lower bound is untouched.
  • Complexity: at most two per-query searches per query (own candidates, then reachable ones up to 128), within a quarter of the first round's time, plus one whole-workload evaluation.
  • Assumptions and limits:
    • First round only. Later pane rounds skip it: their larger graphs make the per-query searches slower, and merge_validated_rounds keeps the first round's plan anyway.
    • Objective searches. The cluster-capacity, complete-catalog, exhaustive and keep-every-candidate searches go through refinement/objectives.rs, which does not call optimize_configured_candidates with this flag; they get no seed.
    • Large shared graphs. A query that reaches more than 128 shared candidates gets no separate plan, so the workload gets no seed and runs as on main. The A filtered query (city = 'NYC') doesn't read its group from a grouped summary another query builds #902 (145 and 243) and [Bug] An exact coarser grouping does not roll up the finer exact result #923 (643) fixtures are such cases; searching them cost the joint search enough time to select a worse plan ([Bug] An exact coarser grouping does not roll up the finer exact result #923: 0.0566/s instead of 0.0067/s).
    • Time. The per-query searches take time from the joint search. The guarantee holds when each query's search finds its own optimum within its share.
    • Invalid union. If the union of valid separate plans is not a valid joint selection (see After this PR), there is no seed.
    • Unfinished first round. If the first round stops on budget or infeasibility, later rounds run without the separate plans.
    • No optimality claim. The combination improves the incumbent, not the lower bound; the reported gap is recomputed for it.
    • Evaluator errors. An error while evaluating the combination keeps the joint result, as on main.

Before this PR

On workloads where the query-time plans rank first, the joint search could return a plan up to 2.8 times the cost of the queries' separate plans, including when the queries share a scan.

After this PR

In the first round, whether or not the queries share candidates, the joint plan costs no more than the queries' separate plans combined, when all of these hold:

  • each query's separate plan is found within its share of the round's time (on its own candidates, or on at most 128 shared ones);
  • the union of the separate plans is a valid selection of the joint graph. It can be invalid while each plan is valid: two queries choosing different producers for one shared consumer, a shared-query materialization limit, or incompatible outputs. Then there is no seed;
  • the first round completes. If it stops on budget or infeasibility, later rounds run without the separate plans;
  • the search is not one of the objective searches.

The comparison is with each query's plan in the first round's (seed-grid) domain, not with planning it alone over the complete pane-width domain and the full budget. Otherwise the search runs as on main.

Verification

  • Failing first:
    • per_city_percentiles_cost_no_more_than_separate_plans (committed alone in the first commit) fails on main: joint 0.2084510 above separate plans 0.1048378.
    • percentile_and_max_over_one_scan_… and percentile_and_grouped_count_over_one_scan_… fail on main and on this PR's first version (seed from own candidates only): 0.1614550 vs 0.0578418 and 0.1762188 vs 0.0796515. They replace grouped_and_filtered_percentiles_…, which passed without the fix.
  • All passed at the head:
    • cargo test -p asap-plan-selection --lib: 374 (as on main; the earlier seed_plans_are_evaluated_first unit test went with the search-loop change);
    • integration suites: independent_plan_seed 3, joint_search_equivalence 14, sharing_issue_regressions 12, production_rollup_workloads 4, identical_exact_queries 2, rollup_controlled 2.
    • Timing note: exact_coarse_sum_reuses_fine_sum ([Bug] An exact coarser grouping does not roll up the finer exact result #923) passes alone (0.0066901 vs base 0.0066551, tolerance 0.00004; twice), but failed once while another planner test ran on the same machine. Its margin is tight and its seed round is time-bound; on this PR its queries get no seed (643 shared candidates), so only the own-candidate search (infeasible in milliseconds) runs before the joint search.
  • Example C on feat(optimizer): reuse a finer aggregate through a compensating filter and a rollup of values or states #952 (a local merge of feat(optimizer): reuse a finer aggregate through a compensating filter and a rollup of values or states #952 and this PR, not pushed): filtered_kll_states_cost_no_more_than_separate_plans passes: together 0.2284651/s (seed-joint-plan-7, the separate plans), grouped alone 0.1760462/s, filtered alone 0.0524189/s; with the first version of this PR it stayed at 0.3320576/s. The grouped query has no plan of its own 38 candidates; over its 64 reachable ones it finds 0.1760462/s.
  • Style: cargo fmt --all --check and cargo clippy -p asap-plan-selection -p asap-integration-tests --all-targets -- -D warnings are clean.

🤖 Generated with Claude Code

@zzylol zzylol added bug Something isn't working optimizer Workload optimization and plan selection search Candidate-plan search space and algorithms labels Oct 27, 2026
zzylol and others added 3 commits October 27, 2026 23:42
Two per-city p90 queries cost 0.1048378/s planned separately but
0.2084510/s planned together; the joint search stops at a query-time
plan. Fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e seed round

The model's lower bounds price query-time scans and builds at zero, so
the joint search can spend its seed-round budget on shared plans and stop
above what planning each query alone gives. Each query is now searched
over the candidates only it can reach; their combination, when valid, is
evaluated before any proposal and becomes the first incumbent.

Two per-city p90 queries: 0.2084510/s before, 0.1048378/s after (the
sum of their separate plans).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… scan, evaluated last

Review fixes for the separate-plans seed:
- A query whose own candidates hold no plan (queries share a group,
  such as one scan) is searched over every candidate it reaches, up to
  128, and its plan is used only when that search proves it optimal.
  Shared candidates appear once in the combination, whose modeled cost
  counts each candidate once.
- The combination is evaluated after the joint search, only when the
  search ended above the queries' summed cost, so it never takes the
  place of one of the search's evaluations; an eighth of the remaining
  time is kept for it.
- It runs in the first round of a search (the seed-grid round, or the
  single complete round of a window-free workload) via the new
  CandidatePlanningContext::seed_separate_plans.
- independent_plan is renamed separate_plans_combined; the search loop
  is unchanged from main; a debug assertion checks components are closed.

Tests: two shared-scan pairs (p90 + MAX, p90 + grouped COUNT) that cost
2.79x and 2.21x their separate plans before; the weak grouped/filtered
test is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zzylol
zzylol force-pushed the fix/seed-independent-plan branch from 9c5cf27 to 32fe000 Compare October 27, 2026 23:42
zzylol and others added 2 commits October 27, 2026 23:49
…d optimum

The size cap already keeps the large shared graphs out; a plan found
within the time limit is still each query's own, and the combination is
kept only if its whole-workload evaluation is cheaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ompute the gap after a replacement

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working optimizer Workload optimization and plan selection search Candidate-plan search space and algorithms

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant