Skip to content

feat(plan-selection): experimental MILP search over the sub-expression memo (--search memo-milp) - #915

Merged
zzylol merged 88 commits into
mainfrom
feat/memo-milp-mvp
Oct 23, 2026
Merged

zzylol merged 88 commits into
mainfrom
feat/memo-milp-mvp

Conversation

@zzylol

@zzylol zzylol commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Experimental. --search memo-milp is opt-in; the legacy search stays the default. This PR is the MVP of the MILP search over the sub-expression memo, merged under a lighter gate (listed under Verification). The full 60-seed three-way test, the slow ignored tests and whole-workload timing are the gate of the follow-up PR.

Part of #879

Why

The legacy and exhaustive searches enumerate whole-workload combinations, which blow up on the evaluation workloads (RangeHeavy quantiles-24h@60s: 359,970 candidates, 617 s, 3.4 GB; RollupHeavy template 10: no plan within 25 min). The memo (#900) represents the workload as groups of equal logical sub-expressions; this PR searches it with a mixed-integer linear program whose answer is checked exactly by Stage 3, so the selected plan is the exhaustive optimum when the reported gap is 0.

What

  • --search memo-milp (SearchAlgorithm::MemoMilp): under cost model 1, a lazy loop over a MILP built from the memo. Cost models 2 and 3 run the legacy search and say so (routed_to_legacy: cost_model_not_supported).
  • Every search reports incumbent_cost (the selected plan's cost per second). The MILP search adds search.milp: lower_bound, first_lower_bound, gap, rounds, variable_count, constraint_count, solver_time_ms, time_limit_reached.
  • --milp-time-limit-s <f64> (Stage1ToStage3Search::milp_time_limit_ms): at the limit the search returns its best evaluated plan with time_limit_reached: true and its gap; it is never called optimal unless the gap is 0. Hitting the limit before any valid plan is an error that says so.
  • A workload with no valid plan names the queries that have none on their own (e.g. "no valid plan for query 2 (quantile by (host, job) (0.5, data)) in a deployment that keeps no raw data").
  • New dependency: good_lp =1.15.3 with the HiGHS solver (MIT).

How

Terms (joint-planning-search design, "Terms"):

  • Coefficient derivation: each choice (a memo candidate at its site thresholds, with a materialization per private slot; scan groups per horizon) gets objective coefficients from Stage 3's per-node cost function, once per choice. Every coefficient is a lower bound on what the choice adds to any plan containing it; terms that do not decompose (held memory × latency, a scan's horizon shared by readers, nodes several groups may share, widened scans) are charged at a lower bound.
  • The model: binary choice per needed group, built-once (once) per shared group, consumer reach through direct parts and slots, union rates of schedule classes for built-once and query-time nodes, held memory over each query's query-time latency (linearized), raw data kept on disk per source, latency bounds, requirements (exact children, coupling sites, rollup rules) and pane/top-k exclusivity as constraints, cross-group nodes and slots charged once per exact key.
  • Lazy loop: solve → MILP lower bound; evaluate the solution's group choice exactly with Stage 3 (the reference search's evaluation of that group choice: every threshold, identical-expression variant and materialization), which updates the incumbent; cut that group choice (and only it); stop when the lower bound reaches the incumbent cost.
  • Prices are scaled per evaluation so HiGHS's tolerances do not swamp small charges; a solve that HiGHS stops within its own gap tolerance counts as optimal with its bound lowered by that tolerance.

Major algorithm changes

Key code interfaces added / modified

crates/plan-selection/src/lib.rs

/// How the Stage 1 to Stage 3 search explores the plans. Stays.
pub enum SearchAlgorithm {
    /// The search over whole-workload candidates (the default). // unchanged
    Legacy,
    /// Every plan of the memo, each evaluated by Stage 3: the reference. // unchanged
    MemoBruteForce,
    /// The memo solved as a MILP whose solutions Stage 3 evaluates exactly. // new
    MemoMilp,
}

/// Why the legacy search ran instead of the requested memo search. New.
pub enum RoutedToLegacy {
    /// The caller asked for the exhaustive search. // new
    ExhaustiveRequested,
    /// Cost models 2 and 3: the MILP prices cost model 1 only. // new
    CostModelNotSupported,
}

/// The search's report. Stays; fields shown are the ones this PR adds.
pub struct SearchReport {
    /// The selected plan's Stage 3 cost per second, every search; None when none was selected. // new
    pub incumbent_cost: Option<f64>,
    /// The MILP search's report; None for every other search. // new
    pub milp: Option<MilpReport>,
    /// The algorithm that ran; omitted in JSON for the legacy search. // new
    pub algorithm: SearchAlgorithm,
    /// Why the legacy search ran instead of the requested one. // new
    pub routed_to_legacy: Option<RoutedToLegacy>,
}

/// The MILP search's bound, gap and effort. New.
pub struct MilpReport {
    /// MILP lower bound at the end on every plan not evaluated. // new
    pub lower_bound: Option<f64>,
    /// The first round's lower bound, before any cut. // new
    pub first_lower_bound: Option<f64>,
    /// (incumbent cost − lower bound) / incumbent cost; 0 = proven optimal. // new
    pub gap: Option<f64>,
    /// Rounds: solve, exact evaluation, cut. // new
    pub rounds: u32,
    /// The last round's model size. // new
    pub variable_count: u64,
    /// The last round's model size. // new
    pub constraint_count: u64,
    /// Time spent in the solver, every round together. // new
    pub solver_time_ms: u64,
    /// Stopped at `milp_time_limit_ms`: optimal only if `gap` is 0. // new
    pub time_limit_reached: bool,
}

pub enum SelectionError {
    /// The MILP search failed: solver error, time limit before any valid
    /// plan, or no valid plan (naming the queries with none). // new
    Solver(String),
    // every other variant unchanged
}

crates/plan-selection/src/pass/stage1_to_stage3.rs

/// How run_stage1_to_stage3_with searches. Stays.
pub struct Stage1ToStage3Search {
    /// Keep every candidate (developer tools). // unchanged
    pub keep_every_candidate: bool,
    /// The exhaustive search. // unchanged
    pub exhaustive: bool,
    /// The default search's algorithm; ignored with `exhaustive`. // was `memo_groups: bool`
    pub search: SearchAlgorithm,
    /// The MILP search's time limit in ms; None for no limit. // new
    pub milp_time_limit_ms: Option<u64>,
}

impl Stage1ToStage3Search {
    /// The default search run with `algorithm`. // new
    pub fn with_algorithm(algorithm: SearchAlgorithm) -> Self;
}

crates/devtools/src/bin/stage_pipeline.rs: --search legacy|memo-brute-force|memo-milp (was legacy|memo-brute-force), --milp-time-limit-s <f64> (new; needs --search memo-milp).

Usage

# The #840 workload: three quantiles every 10 s over 10,000 series sampled
# every 15 s, raw data kept; the experimental MILP search with a 10-minute limit.
stage_pipeline --workload wl840.json \
  --deployment-capabilities '{"raw_data_retained":true,"query_time_results_retained":false}' \
  --search memo-milp --milp-time-limit-s 600 --out wl840.memo-milp.json

The planner lowers the three queries (Stage 0), builds per-query candidates (Pass 1), builds the memo of 8 groups, derives coefficients for a model of 1,198 variables, then runs rounds: HiGHS solves the model, Stage 2 and Stage 3 evaluate the group choice it names, and a cut removes that group choice. The document's stage3_plan_selection.search then reads:

{"algorithm": "memo-milp", "incumbent_cost": 0.1477615599424,
 "milp": {"lower_bound": 0.1477615599424, "first_lower_bound": 0.141229058, "gap": 0.0,
          "rounds": 73, "variable_count": 1198, "constraint_count": 1603,
          "solver_time_ms": 321471, "time_limit_reached": false},
 "memo": {"groups": 8, "candidates": 39, ...}}

with the selected plan's cost equal to --exhaustive's (0.1477616 per second): one raw scan of 200,000 samples (5 minutes) read by both windows, a KLL build per window at query time (40,000 and 200,000 rows into 10,000 states), and a separate 15-second scan with an exact hash aggregate for the third query.

Before this PR

--search offered legacy and memo-brute-force. The brute force evaluates every plan of the memo, so it is no faster than the exhaustive search on large workloads.

After this PR

--search memo-milp selects the exhaustive optimum where it certifies gap 0 (below), reports how far from proven it is otherwise, and stays opt-in.

Verification

Checks that ran (the lighter MVP gate), on head a1f1a62 (the last commit only changes the error path after a time limit and a doc line); runs share one machine with other jobs:

Check Result
Model ≤ exact for every group choice (lower_bound_check over all group choices) committed tests (the_model_is_a_lower_bound_*, raw data on and off): #840's shape, a shared group with a rollup, #839's shape, a sketch rollup, a query over two groups of one metric. Ad-hoc runs (not committed): 0 violations on random seed 43, #840 (3 queries), two #840 variants, #839 raw and no raw
#840, 3 queries, raw certified: gap 0, 0.1477616 = --exhaustive, 73 rounds, 322 s, 123 MB (exhaustive 4.3 s, 134 MB)
RangeHeavy quantiles-24h@60s, raw, Fargate, guarantees enforced certified: gap 0, 8.73884e-05 = main, 129 rounds, 735 s, 194 MB (main 617 s, 3.4 GB)
#840 whole workload (11 groups) not certified: time limit (1800 s), gap 0.042, incumbent cost 0.1507878 = exhaustive 0.150788, 168 rounds: follow-up
#839 no raw data gap 0 against the memo, 129 rounds, 56 s: 115.66951 = --search memo-brute-force; --exhaustive selects 115.66801 (1.3e-5 relative cheaper), a memo-coverage gap, not the MILP's: follow-up
#839 raw data not certified: time limit (1800 s), gap 0.341, incumbent cost 113.95144 vs exhaustive 113.94994, 64 rounds: follow-up
Three-way (exhaustive = memo brute force = memo MILP, gap 0) on the 20 smallest random seeds, cost model 1 passed (three_way_twenty_smallest_random_workloads_under_cost_model_1, release, 137 s)
cargo test of plan-selection and devtools passed (plan-selection unit tests incl. lower_bound 4/4 with raw data on and off; stage_pipeline 45, incl. the time-limit and search-flag tests)
fmt, clippy -D warnings on the changed crates clean

Not run here (follow-up gate): the full 60-seed three-way under cost model 1, the slow ignored equivalence tests, whole paper workloads.

Also: two stage_pipeline tests from #908 shared temporary file names and failed when run in parallel; they now take per-call names.

zzylol added 30 commits October 22, 2026 17:02
…pability, window/rollup/top-k transformations, per-group pricing (behind no flag yet)
…, budget-share variants, topological order

Each Pass 1 candidate's private nodes read their child groups through the
groups' own nodes and output the group's schema. A sketch candidate is
offered once per share of its measure's budget a reader may give it
(budget_share), composed by the new compose_logical_candidate_with_budget_share.
Memo::topological_order orders groups after every group they or their
candidates read.
ScanLabels sites per metric whose scans project different labels, with
WidenedScans twins, label thresholds and widening at a threshold; and
RawHorizon sites per source whose raw windows two or more groups read.
Identical expression tested as group identity.
…nts, sketch rollup over a finer group's state, groups kept in topological order
…(exact-child requirements, sketch rollup) into the memo
price_at_scale's per-node loop body is now price_node, which takes the
node's producers' outputs, its computation rate, a query-time scan's
look-back, a chain's retained panes and an ingestion-time node's window,
and returns the node's output statistics, usage, cost and the charges
known only once the whole plan is (held memory, partial copies, raw
data). price_at_scale calls it per node; prices are unchanged.
…roups, scan materializations

A shared-build site keys its input by structure, so equal windows of two
queries (one group each without a unique key) meet. Candidates list the
materializations Stage 2 may give them; a scan group read through one
window is only built once, one read through different windows may also
be read per reader. Equal SQL table scans are one group.
… grouping's pane chain; sketch rollup state resized to a threshold; required sites; one topological order
…eep their own columns

A lib test composes every whole-workload Pass 1 choice of the random
workloads of joint_search_equivalence and checks each query's plan is a
combination of the memo's per-group candidates. It found the fused top-k
heap candidates of an ungrouped top-k root dropped because their output
columns differ from the logical node's; a candidate keeps its own schema
when its columns differ, and every candidate of a group a parent reads
outputs the group's schema. Equal SQL table scans are one group.
…r context

price_at_scale's node loop is split into price_each_node, and lib.rs gains
price_private_nodes: Stage 3's per-node pricing of a candidate's private
nodes for given consumers (queries, or materialized ancestors with their
rate), reading its children's given outputs, with computations()'
counting restricted to them. Memory held for an evaluation and raw data
on disk stay plan-level terms (HeldMemory, raw scans), priced by
plan_cost once each query's latency is known.

subexpr/pricing.rs: private_usage, output_stats, plan_cost and
frozen_calibration, cached by (candidate key, consumers, materialization,
child outputs); frontiers dropped (cost model 1 only). Tests: the groups'
local prices add up to price_at_scale of the composed plan for several
one-query plans, a shared group not materialized and materialized at
query time (equal and different intervals), and a pane chain at
ingestion.
…threshold

resized_at_threshold resizes a shared-build reader's build to a threshold
(with_resized_build, guarantees composed again, child groups' nodes kept).
The brute-force reference rewrites SharedBuildSize sites with it and makes
resized builds over equal windows one build.
… 1 parity test checks every target's candidates

A memo plan built from per-group candidates kept the frontend's empty
guarantee on a group's own node (an exact top-k over the per-series
sums' exact accumulator), so Stage 3 rejected it for having no
guarantee. composed_roots now recomposes every guarantee, as Pass 1
composition does (recompose_guarantees made public). The parity test
also checks that each target's group, absorbed or not, has the target's
own and fused candidates.
… and lists the identical-expression and scan-widening variants as the exhaustive search does
zzylol added 20 commits October 22, 2026 22:20
# Conflicts:
#	crates/devtools/src/bin/stage_pipeline.rs
#	crates/devtools/tests/stage_pipeline.rs
#	crates/plan-selection/src/pass/stage1_to_stage3.rs
# Conflicts:
#	crates/devtools/src/bin/stage_pipeline.rs
#	crates/devtools/src/planning_stages.rs
#	crates/devtools/tests/stage_pipeline.rs
#	crates/integration-tests/tests/joint_search_equivalence.rs
#	crates/plan-selection/src/lib.rs
#	crates/plan-selection/src/pass/stage1_to_stage3.rs
#	crates/plan-selection/src/pass/subexpr/mod.rs
#	crates/plan-selection/src/pass/subexpr/pricing.rs
#	crates/plan-selection/src/pass/subexpr/scans.rs
#	crates/plan-selection/src/pass/subexpr/search.rs
#	crates/plan-selection/src/pass/subexpr/windows.rs
…the merge had dropped; --search memo-milp and --milp-time-limit-s on top
…ap tolerance counts as optimal with its bound lowered by that tolerance (was reported as a time-limit stop)
… plan is still priced and the best plan returned with its gap (relaxation bound when no round finished); infeasibility names the queries with no plan
…iables/constraints, first_model_bound, time_limit_reached
…ound, gap, rounds and effort in a nested MilpReport (search.milp)
…idened scans

Slots whose builds are equal over equal inputs may be one node (a site's
build at one threshold, or identical expressions): each slot carries its
build's structural key, and the model charges the dearest chosen one
once. Scan choices of one source may be one widened scan: the model
charges the dearest scan choice of each source. The model's input and
the per-group-choice lower-bound check are functions
(model_input_of, lower_bound_check), and a test asserts the model is a
lower bound of every group choice on #840's and #839's shapes.
Shared slots and shareable nodes several groups' choices may have are
keyed loosely (kind, family, panes, raw sources) and charged once per
key, at the dearest chosen one; their held memory is left out of the
per-choice held term. A scan choice is charged a pass over its output
per reader plus, once per source, the largest excess over it (a widened
scan). rollup_rules reads the finer site the rollup itself requires.
Tests: the model is a lower bound of every group choice on #840's shape,
its sketch rollup, and #839's shape.
… widening

A slot's build and a shareable node are keyed by their payload over their
producers' keys, each scan by its source alone (the identical-expression
rule may widen a source's scans to one label set), schemas left out. So
the model charges the dearest of several groups' choices only for nodes
that are one node once shared, and the per-choice sum otherwise.
A scan apart is its reader's own, and a shared one is its scan group's
choice: counting absorbed scans as shareable nodes of several groups let
the model charge only the dearest of every group's scan.
…oss groups

Each query-time node of a choice's direct part (scans aside) carries its
key up to scan widening; nodes several groups' choices have with one key
are one node once shared (identical expressions over widened scans), and
the model charges the dearest chosen one once. A choice's own scans are
charged like scan choices: a pass per reader plus the largest excess
once per source. A choice lists the children its plan reads first, with
their child_via; children its candidate lists without reading (a
rollup's own window) stay needed but no consumer passes into them.
…ot consumers only of chosen choices

A node several groups' choices have (equal up to scan widening) costs
its charge per consumer, or built once at least the dearest chosen one's
at its group's slowest rate: the model charges the smaller. A slot's
consumer reaches and passes into nothing unless its choice is chosen.
A shareable node's part leaves its scans to the scan charges. A debug
test dumps a seeded random workload as stage_pipeline JSON.
…once group

A slot's consumer reaches, passes into and is charged by nothing unless
its choice is chosen; a group's own consumer unless the group is built
once. Coefficient tests charge a built-once node's scans once.
…t the union rate

The scale came from the largest charge including slots' fixed costs: a
slot keeping tens of GB of state shrank every other charge below the
solver's dual tolerance, so the solver stopped above the optimum with
costly variables left at 1 (seed 43: 0.3% over the exact price). It is
now the largest per-evaluation charge at the fastest query's rate.

A part several queries of one class read runs at their union rate, so
each reader's held memory is charged at union/sum of its rate.
…y test over the 20 smallest random workloads
# Conflicts:
#	crates/devtools/tests/stage_pipeline.rs
@zzylol zzylol added enhancement New feature or request optimizer Workload optimization and plan selection search Candidate-plan search space and algorithms labels Oct 23, 2026
…ver time-limit stop with no solution keeps the incumbent; query latency leaves out nodes another group may share; the lower-bound tests also run without raw data, and over a query reading two groups of one metric
A query over two groups of one metric reads one scan node and one node
per group for its schedule class, where the model charged each reader:
- a scan's pass is charged once per identical scan (source and price),
  the largest of its readers' charges;
- a query-time slot's raw scans come out of its slot charge and are
  charged as scans of their source, so two slots over one scan share it;
- a choice's direct charge for queries of one class is their largest,
  not their sum.
…known instead of failing; the report says any solver limit
@zzylol
zzylol merged commit 6e53384 into main Oct 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request 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