From d99c1f96914e5edc066754ac5d2763203af87b4c Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:26:33 +0000 Subject: [PATCH 1/9] feat(plan-selection): group selection MILP over execution contexts behind ASAP_STAGE3_SEARCH=milp (#1095) Step 1 of #1095's solving strategy: one MILP with a warm start. The owner-model pieces from tag stage3-owner-milp-redesign are restored and the owner/instance model is replaced by execution contexts: each query evaluation, the ingestion pipeline I (NotMaterialized ingestion-time candidates) and each shareable (materialized) candidate's own inputs. - groups.rs (was owners.rs): RunKind, ResultSlot (group roots split by self-read depth; private nodes their own slot), execution contexts and their options; dominance, per-candidate usage and accuracy weights kept. Scan windows are costed at every union of the window kinds read within one context, as plan_cost prices them. - milp/model.rs: p[ctx, c], x[m], a[q, m], rows 1-9, slot links for inputs over several result slots, order ranks within and across contexts, latency in scaled units. - milp/cost_terms.rs: run terms (plain, producer-dependent, exact scan window sets), schedule-group rates, held memory, raw retention and pane retention levels. - milp/warm_start.rs: each query's cheapest plan, then greedy sharing, costed exactly by the completion. - milp/decode.rs: one node per (context, computed candidate) and per selected shareable candidate. - asap-cost: a NotMaterialized ingestion-time node runs once per second for every consumer; a NotMaterialized node a materialized node computes is held until that node's slowest reader finishes. The old engine stays the default. --- crates/cost/src/execution_demand.rs | 14 +- crates/cost/src/lib.rs | 6 +- crates/cost/src/usage.rs | 8 +- crates/devtools/src/bin/stage_pipeline.rs | 8 +- crates/devtools/tests/stage_pipeline.rs | 168 ++ crates/devtools/tests/viewer_contract.rs | 2 + crates/plan-selection/src/groups.rs | 1993 +++++++++++++++++ .../src/groups/accuracy_positions.rs | 567 +++++ crates/plan-selection/src/groups/tests.rs | 828 +++++++ crates/plan-selection/src/lib.rs | 11 +- crates/plan-selection/src/milp/completion.rs | 306 +++ crates/plan-selection/src/milp/cost_terms.rs | 1175 ++++++++++ crates/plan-selection/src/milp/decode.rs | 162 ++ crates/plan-selection/src/milp/mod.rs | 22 + crates/plan-selection/src/milp/model.rs | 1860 +++++++++++++++ crates/plan-selection/src/milp/model/tests.rs | 961 ++++++++ crates/plan-selection/src/milp/warm_start.rs | 780 +++++++ crates/plan-selection/src/report.rs | 47 + crates/plan-selection/src/search/select.rs | 48 +- crates/plan-selection/src/select.rs | 348 +++ crates/plan-selection/src/select/tests.rs | 175 ++ crates/planner/src/run.rs | 16 + crates/types/src/stages/pruning.rs | 20 +- tools/dag-viewer/stages.js | 2 +- 24 files changed, 9477 insertions(+), 50 deletions(-) create mode 100644 crates/plan-selection/src/groups.rs create mode 100644 crates/plan-selection/src/groups/accuracy_positions.rs create mode 100644 crates/plan-selection/src/groups/tests.rs create mode 100644 crates/plan-selection/src/milp/completion.rs create mode 100644 crates/plan-selection/src/milp/cost_terms.rs create mode 100644 crates/plan-selection/src/milp/decode.rs create mode 100644 crates/plan-selection/src/milp/mod.rs create mode 100644 crates/plan-selection/src/milp/model.rs create mode 100644 crates/plan-selection/src/milp/model/tests.rs create mode 100644 crates/plan-selection/src/milp/warm_start.rs create mode 100644 crates/plan-selection/src/select.rs create mode 100644 crates/plan-selection/src/select/tests.rs diff --git a/crates/cost/src/execution_demand.rs b/crates/cost/src/execution_demand.rs index 6a7e380bd..e39d5028f 100644 --- a/crates/cost/src/execution_demand.rs +++ b/crates/cost/src/execution_demand.rs @@ -9,6 +9,7 @@ use super::analytical::AnalyticalCostError; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPNodeId}; +use asap_types::ir::properties::ExecutionTiming; use asap_types::physical::Materialization; use asap_types::workload::{QueryRecurrence, RangeEvaluation, RepeatedDemand}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -345,11 +346,9 @@ pub fn consumers(dag: &PhysicalASAPDAG) -> HashMap (1, rate), + None if n.output_state.timing == ExecutionTiming::IngestionTime + && count.contains_key(&n.id) => + { + (1, 1.0) + } None => count.get(&n.id).copied().unwrap_or((0, 0.0)), }; (n.id, times) diff --git a/crates/cost/src/lib.rs b/crates/cost/src/lib.rs index 06878f0b0..e2570f9f3 100644 --- a/crates/cost/src/lib.rs +++ b/crates/cost/src/lib.rs @@ -41,9 +41,9 @@ pub use measured::{ pub use operator_statistics::EdgeStatistics; pub use ranking::{cheaper, worst_latency_ms, PlanRanking}; pub use usage::{ - family_name, kept_tumbling_window_chains, node_map, node_usage, plan_cost, query_reach, - trailing_window, tumbling_window_retention_ms, tumbling_window_retention_ms_of_plan, - tumbling_windows_of, NodeMap, NodeUsage, ProducerOutput, + family_name, kept_tumbling_window_chains, maintained_window_ms, node_map, node_usage, tumbling_window_retention_ms, + tumbling_window_retention_ms_of_plan, tumbling_windows_of, plan_cost, query_reach, retained_tumbling_windows, trailing_window, + NodeMap, NodeUsage, ProducerOutput, }; /// Cost per second of wall time; one unit is one CPU-millisecond under diff --git a/crates/cost/src/usage.rs b/crates/cost/src/usage.rs index fe4ee9f11..f811145d4 100644 --- a/crates/cost/src/usage.rs +++ b/crates/cost/src/usage.rs @@ -1487,9 +1487,9 @@ fn merged_states( }) } -/// The tumbling windows a chain in `retention_ms` ([`tumbling_window_retention_ms`]) keeps -/// completed: its retention over its tumbling-window width, at least its tumbling-window count. -fn retained_tumbling_windows( +/// The panes a chain in `retention_ms` ([`tumbling_window_retention_ms`]) keeps +/// completed: its retention over its pane width, at least its pane count. +pub fn retained_tumbling_windows( node: &asap_types::ir::physical_export::PhysicalASAPDAGNode, retention_ms: &BTreeMap, ) -> u64 { @@ -1546,7 +1546,7 @@ pub fn query_reach( /// The window an ingestion-time grouping `node` maintains: a tumbling-window chain's /// tumbling windows, or the time range below it (through filters, projections and /// time shifts). `None` when there is none. -fn maintained_window_ms( +pub fn maintained_window_ms( node: PhysicalASAPNodeId, nodes: &NodeMap<'_>, producers_of: &HashMap>, diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 6889a6e3f..c59d023b1 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -72,8 +72,12 @@ // accepted regardless of guarantee, `accepted_regardless_of_guarantee`, // `guarantee_check_rejection`, the sketches read and a one-line summary), // `pruned_candidates` and `search_report` (`SearchReport`: `status`, -// `cost_lower_bound_per_second`, MILP size and timings); `error` when no -// plan was selected. `tumbling_window_retention_ms` maps each tumbling-window chain the +// `cost_lower_bound_per_second`, MILP size and timings, and +// `inexact_objective` when the new search's objective and `plan_cost` +// differ over terms the MILP encodes inexactly). With the environment +// variable `ASAP_STAGE3_SEARCH=milp`, Stage 3 runs #1095's selection MILP +// instead of the old engine (until it becomes the default); `error` when no +// plan was selected. `tumbling_window_retention_ms` maps each pane chain the // selected plan keeps beyond one evaluation (by node id in `P1`) to how // far back the runtime must keep its tumbling windows (`RealizedPlan::tumbling_window_retention_ms`). // `materialized_views` are the selected plan's materialized views as diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index b799ba449..b384b1607 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -2552,3 +2552,171 @@ fn memo_section_lists_each_step_the_selected_choice_and_the_enumeration() { std::fs::remove_file(out).unwrap(); assert!(document.get("memo").is_none()); } + +/// `stage_pipeline`'s document with Stage 3's new search on +/// (`ASAP_STAGE3_SEARCH=milp`, #1095 C5), set on the child process only. +fn document_with_milp_search(args: &[&str]) -> Value { + static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let out = std::env::temp_dir().join(format!( + "stage_pipeline_{}_milp_{call}.json", + std::process::id(), + )); + let status = Command::new(env!("CARGO_BIN_EXE_stage_pipeline")) + .env("ASAP_STAGE3_SEARCH", "milp") + .args(args) + .arg("--out") + .arg(&out) + .status() + .unwrap(); + assert!(status.success()); + let document = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); + std::fs::remove_file(out).unwrap(); + document +} + +/// #1095 C5: with the new search on, example 1 and the RangeHeavy T5 +/// q0.5 + q0.9 workload select a plan through the real pipeline, solved to +/// optimality or stopped at the deadline, whose `plan_cost` equals the MILP's objective exactly (the +/// search errors on a difference and reports `inexact_objective` when the +/// model encoded a term inexactly) and which passes every query's accuracy +/// check. +#[test] +fn milp_search_objective_equals_plan_cost_on_example1_and_rangeheavy() { + let rangeheavy = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tools/dag-viewer/memo-examples/promql/rangeheavy-t5-q05-q09-1h.json"); + let runs: [Vec<&str>; 2] = [ + vec![ + "--example", + "planner-layering-1", + "--enforce-accuracy-guarantees", + ], + vec![ + "--workload", + rangeheavy.to_str().unwrap(), + "--deployment-capabilities", + r#"{"raw_data_retained": true, "query_time_results_retained": false}"#, + "--cost-model", + "aws-fargate-us-east-1", + "--enforce-accuracy-guarantees", + ], + ]; + for args in runs { + let document = document_with_milp_search(&args); + let selection = &document["stage3_plan_selection"]; + assert!(selects_a_plan(selection), "{args:?}: {selection}"); + let report = &selection["search_report"]; + // Optimal or not by the deadline, the selected plan's `plan_cost` + // equals the MILP's objective: the search errors on a difference, + // and no term is encoded inexactly here. + assert!( + report["status"] == "optimal" || report["status"] == "deadline_reached", + "{args:?}: {report}" + ); + assert!( + report.get("inexact_objective").is_none(), + "{args:?}: {report}" + ); + assert!(report["milp_variables"].as_u64().unwrap() > 0); + let queries = document["workload"]["queries"].as_array().unwrap().len(); + let accepted = selection["accepted_accuracy_per_query"].as_array().unwrap(); + assert_eq!(accepted.len(), queries); + for query in accepted { + assert_eq!(query["accepted_regardless_of_guarantee"], false, "{query}"); + } + } +} + +/// #1127 regressions: with the new search on and guarantees enforced, the +/// memo examples on which the A1 rows admitted a plan that `satisfies` +/// rejects now select a plan that passes every query's accuracy check: +/// ratio-of-quantiles (KLL rank errors compose no guarantee through the +/// quotient, so no query can use KLL) and rollupheavy-t11 (HLL's +/// failure probability is 1 over the declared 10^6 distinct users, above +/// its contract's 2^17, so Pass 1 prunes it for both queries). +#[test] +fn milp_search_selects_checked_plans_on_ratio_and_rollupheavy() { + let root = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tools/dag-viewer/memo-examples"); + let tables: Vec = serde_json::from_str::>( + &std::fs::read_to_string(root.join("sql/tables.json")).unwrap(), + ) + .unwrap() + .iter() + .map(Value::to_string) + .collect(); + for (workload, pruned_family) in [ + ("promql/ratio-of-quantiles-5m.json", "Kll"), + ("sql/rollupheavy-t11-service-endpoint-5m.json", "Hll"), + ] { + let path = root.join(workload); + let mut args = vec![ + "--workload", + path.to_str().unwrap(), + "--deployment-capabilities", + r#"{"raw_data_retained": true, "query_time_results_retained": false}"#, + "--cost-model", + "aws-fargate-us-east-1", + "--enforce-accuracy-guarantees", + ]; + if workload.starts_with("sql/") { + for table in &tables { + args.extend(["--table", table.as_str()]); + } + } + let document = document_with_milp_search(&args); + let selection = &document["stage3_plan_selection"]; + assert!(selects_a_plan(selection), "{workload}: {selection}"); + let queries = document["workload"]["queries"].as_array().unwrap().len(); + let accepted = selection["accepted_accuracy_per_query"].as_array().unwrap(); + assert_eq!(accepted.len(), queries); + for query in accepted { + assert_eq!(query["accepted_regardless_of_guarantee"], false, "{query}"); + assert!( + !query["sketches"] + .as_array() + .unwrap() + .iter() + .any(|sketch| sketch == pruned_family), + "{workload}: {query}" + ); + } + let pruned = pass1_pruned(&document); + assert!( + pruned.iter().any(|(family, reason)| family == pruned_family + && reason == "NoErrorBoundForQueryTarget"), + "{workload}: {pruned:?}" + ); + } +} + +/// #1095 C5: on RangeHeavy T8+T5 (`q0.9 / q0.5` with both operands also +/// queried alone), the new search selects a DDSketch plan that passes every +/// query's accuracy check (the A1 rows admitted KLL there, which the check +/// rejects). Which chains it shares depends on the deadline; the weights +/// that let one chain serve all three queries are checked in +/// `groups::tests::one_pane_chain_serves_a_ratio_and_its_operands`. +#[test] +fn milp_search_selects_a_checked_ddsketch_plan_for_a_ratio_and_its_operands() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tools/dag-viewer/memo-examples/promql/rangeheavy-t8-t5-1h.json"); + let document = document_with_milp_search(&[ + "--workload", + path.to_str().unwrap(), + "--deployment-capabilities", + r#"{"raw_data_retained": true, "query_time_results_retained": false}"#, + "--cost-model", + "aws-fargate-us-east-1", + "--enforce-accuracy-guarantees", + ]); + let selection = &document["stage3_plan_selection"]; + assert!(selects_a_plan(selection), "{selection}"); + for query in selection["accepted_accuracy_per_query"].as_array().unwrap() { + assert_eq!(query["accepted_regardless_of_guarantee"], false, "{query}"); + assert_eq!( + query["sketches"], + serde_json::json!(["DDSketch"]), + "{query}" + ); + } +} diff --git a/crates/devtools/tests/viewer_contract.rs b/crates/devtools/tests/viewer_contract.rs index 5e7b76826..23680f0f4 100644 --- a/crates/devtools/tests/viewer_contract.rs +++ b/crates/devtools/tests/viewer_contract.rs @@ -353,6 +353,7 @@ fn viewer_knows_every_view_refresh_and_rejection_cause() { C::LatencyBoundExceeded, C::LatencyOverRepeatInterval, C::OverClusterCapacity, + C::Infeasible, C::Costlier, C::Slower, C::MoreMemory, @@ -366,6 +367,7 @@ fn viewer_knows_every_view_refresh_and_rejection_cause() { | C::LatencyBoundExceeded | C::LatencyOverRepeatInterval | C::OverClusterCapacity + | C::Infeasible | C::Costlier | C::Slower | C::MoreMemory diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs new file mode 100644 index 000000000..856fe3c5b --- /dev/null +++ b/crates/plan-selection/src/groups.rs @@ -0,0 +1,1993 @@ +//! #1095 C3: what the owner-scoped candidate MILP (§2.0) is built from. Given +//! Stage 2's physical candidates (one per operator node, timing and +//! materialization; Stage 2 makes no copies), it computes: +//! +//! 1. **Candidate dominance** ([`prune_dominated_candidates`]): a candidate no optimal +//! plan under cost model 1 uses is deleted, with its links. +//! 2. **Per-candidate data** ([`CandidateData`]): whether the candidate outputs +//! approximate values and the budget parts it is sized for, and its usage +//! per execution, standing cost, held bytes and own latency, every number +//! from [`asap_cost::node_usage`]. +//! 3. **Owners** ([`OwnerRegion`]): one per query and one per possible +//! instance of each shareable candidate, each with its region of stateless +//! candidates, the links inside it and the shareable candidates it may read. +//! 4. **Accuracy weights** ([`AccuracyWeight`]) per query and approximate +//! candidate, and pairs excluded together, from the composition of the +//! query's outputs. +//! +//! Nothing calls it yet; the MILP (C4) will. +//! +//! ## Usage per execution and its context +//! +//! A candidate's usage is [`asap_cost::node_usage`] of its operator node at one +//! execution per second (`rate = (1, 1.0)`); its standing cost is the same +//! call's cost at no execution (`rate = (1, 0.0)`), and its execution cost +//! the difference. `node_usage` also needs facts the candidate alone does not +//! fix, its *context*: +//! - the outputs of the producers it reads (one per input; the producer +//! candidates of an input may output different statistics); +//! - for a query-time scan, the window each reader reads, through time +//! shifts ([`window_read_through`], which follows +//! [`PhysicalASAPDAG::scan_window`] for a plan with that one reader); +//! - for a pane chain kept beyond one evaluation, the panes it keeps, which +//! grow with the range-evaluation span of the queries reaching it +//! ([`asap_cost::pane_retention_ms`], [`asap_cost::retained_panes`]). +//! +//! Every combination of these options is costed (at most +//! [`MAX_CONTEXTS`]); a combination `asap-cost` cannot cost is skipped +//! (no plan can use it). When the numbers agree, the usage is +//! plan-independent; otherwise [`CandidateData::usage_depends_on`] says which +//! options changed it, and [`CandidateData::usage`] holds the first costed +//! combination's numbers. An ingestion-time node's maintained window is +//! read from the first producer candidate of each input +//! ([`asap_cost::maintained_window_ms`]). +//! +//! Candidate links can close a cycle (on #1036 workload-01, a widened scan's +//! `Project` that reads its own memo group). Candidates are costed producers first; on a +//! cycle, a candidate is costed from its producers already costed and marked +//! [`UsageContext::ProducerOnACycle`]. + +//! ## Dominance (exact under cost model 1) +//! +//! Candidate `k` is deleted when another candidate `k'` (the *dominating* candidate): +//! - is in the same memo group, at the same timing and materialization, +//! with the same held state; +//! - has the same producer candidates per input, the same readers (every +//! (reader candidate, input) that may read `k` may read `k'`, and nothing +//! else), the same output references it may produce, the same reader +//! constraints and keep flags; +//! - has the same `(approx, parts)`; +//! - has a guarantee at least as strong ([`guarantee_at_least_as_strong`]); +//! - outputs the same schema, sources, state family and unchanged columns +//! with an operator of the same kind (pane chains: the same panes), so its +//! readers are costed with the same formulas; +//! - and has every number at most `k`'s: CPU operations and scanned bytes +//! per execution, latency (work per execution), output rows and bytes, +//! kept memory (average and peak), disk bytes, held bytes and raw data +//! kept. +//! +//! Both candidates' usage must be plan-independent (otherwise the comparison +//! would hold for one context only). Readers' usage is assumed +//! non-decreasing in their inputs' rows and bytes. Among candidates that +//! dominate each other the one with the smallest id is kept. Every link to +//! a deleted candidate is deleted; its readers still read the dominating candidate. +//! +//! ## Accuracy weights +//! +//! `w(q, k)` says whether query `q` may read approximate candidate `k` (a +//! sketch [`ASAPOp::SummaryAgg`] or [`ASAPOp::PaneChain`], what +//! [`asap_accuracy::propagation::approximate_inputs`] counts), and pairs of +//! approximate candidates `q` may not read together. Both come from the +//! composition itself, never from an operator table +//! ([`accuracy_positions`]): `q`'s outputs are built over the candidates +//! with `k` approximate and every other candidate exact, their guarantees +//! composed by `asap-accuracy` ([`asap_accuracy::propagation::composed_guarantee`]) +//! and checked by the same check the selected plan passes +//! ([`crate::checks::guarantee_check`], with the deployment's model): +//! - every output exact: `k`'s values do not reach the output (only a row +//! count does), and `k` gets no weight; +//! - every output meets the target: `w(q, k) = 1`, one share of `q`'s +//! budget ([`asap_accuracy::budget`]); `k`'s sizes are those of its +//! `parts`, so a lone denominator sized for the whole target +//! (`ε/(1 − ε) > ε`) fails here; +//! - otherwise [`AccuracyWeight::Unusable`], with the check's cause +//! ([`UnusableReason`]): e.g. a rank error through any arithmetic, or an +//! HLL whose declared distinct bound is outside its contract. +//! +//! For each pair `k1, k2` of candidates usable alone, both sized for at +//! least two shares, whose values meet in one plan of `q` (under two inputs +//! of one candidate, under two references of a scalar root, or one below +//! the other), the outputs are built and checked with both approximate; a +//! pair that fails is excluded (`a[q, k1] + a[q, k2] ≤ 1`). A pair with a +//! member sized for one share is excluded by the shares row already. The +//! rows still bound the shares a plan reads by each candidate's `parts`, +//! which the equal split proves for one product or quotient of two inputs; +//! three or more approximate inputs are not built together. +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use asap_cost::{CostInputs, NodeUsage, ProducerOutput}; +use asap_types::ir::physical_export::{ + PhysicalASAPDAG, PhysicalASAPDAGNode, PhysicalASAPNodeId, ScanWindow, +}; +use asap_types::ir::properties::execution_data_state::{DataPrimitive, ExecutionDataState}; +use asap_types::ir::properties::{AccuracyGuarantee, ExecutionTiming}; +use asap_types::ir::schema::{FieldDataType, Schema}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; +use asap_types::physical::Materialization; +use asap_types::stages::{ + output_references, MemoGroupId, OperatorNodeOfCandidateForMemoGroupId, + PhysicalASAPMemoCandidates, +}; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +use crate::SelectionModels; + +use asap_types::stages::{PhysicalCandidateId, ReaderConstraint}; + +/// The most contexts (combinations of producer outputs, scan windows and +/// pane retentions) one candidate is costed under; beyond it the candidate's usage +/// is marked [`UsageContext::TooManyContexts`]. +pub(crate) const MAX_CONTEXTS: usize = 256; + +/// What the MILP is built from (module documentation). +#[derive(Debug, Clone)] +pub(crate) struct MilpInputs { + /// Indexed by [`PhysicalCandidateId`]: each candidate's data, pruned or not. + pub(crate) candidate_data: Vec, + /// The candidates dominance deleted, ascending. + pub(crate) pruned_candidates: Vec, + /// Indexed by [`PhysicalCandidateId`]: per input, the producer candidates left + /// after pruning (empty for a pruned candidate). + pub(crate) producer_candidates_per_input: Vec>>, + /// Per output reference (`output_references` of the query roots): the + /// candidates left that may produce it, `Out(r)`, ascending. + pub(crate) output_candidates: Vec>, + /// Indexed by [`PhysicalCandidateId`]: where it runs. + pub(crate) kind: Vec, + /// Indexed by [`PhysicalCandidateId`]: the result it gives. + pub(crate) slot: Vec, + /// The execution contexts: one per query (`0..Q`, in workload order), + /// then the ingestion pipeline `I` (index `Q`), then one per shareable + /// candidate some context may read, ascending by candidate. + pub(crate) contexts: Vec, + /// Per shareable candidate with a context, its index in `contexts`. + pub(crate) context_of_shareable: BTreeMap, + /// Per query: `w(q, k)` for each approximate candidate `k` whose values + /// reach its output. + pub(crate) accuracy_weights: Vec>, + /// Per query: the pairs `(k1, k2)`, `k1 < k2`, of candidates usable alone + /// that it may not read together. + pub(crate) accuracy_excluded_pairs: Vec>, +} + +/// One candidate's data for the MILP. +#[derive(Debug, Clone)] +pub(crate) struct CandidateData { + /// Whether it outputs approximate values: a sketch `SummaryAgg` or + /// `PaneChain`, each an approximate input of the queries reading it. + pub(crate) approximate: bool, + /// `parts(k)`: its operator node's `accuracy_budget_parts` (Stage 2's + /// `OperatorNodeOfCandidateForMemoGroup`: for a factored node, the + /// largest among its candidates). + pub(crate) accuracy_budget_parts: usize, + /// Its usage under the first context, or why `asap-cost` could not cost + /// it. + pub(crate) usage: Result, + /// The context options that changed its usage, ascending; empty when it + /// is plan-independent. + pub(crate) usage_depends_on: Vec, + /// The inputs whose producer (or that producer's output) changes its + /// usage or its own output, ascending. + pub(crate) inputs_changing_usage: Vec, + /// Its usage and its own output (an index below `output_count`) per + /// choice of a producer and that producer's output for each of + /// `inputs_changing_usage` (in that order); `None` when `asap-cost` could + /// not cost that choice, so no plan can make it. Empty when + /// `inputs_changing_usage` is. + pub(crate) usage_per_producers: Vec<(Vec, Option)>, + /// For a query-time scan whose usage depends on the window its reader + /// reads ([`UsageContext::ScanWindowOfReader`]): its usage per window it + /// can be costed at, with its output there. Empty otherwise. + pub(crate) usage_per_window: Vec<(ScanWindow, CandidateUsage, usize)>, + /// How many distinct outputs (statistics its readers are costed from) + /// it can give, by context. + pub(crate) output_count: usize, + /// For a query-time scan: per reader, the windows it may read the scan + /// at (through time shifts, [`window_read_through`]). Empty for any + /// other candidate. + pub(crate) windows_per_reader: Vec<(PhysicalCandidateId, Vec)>, + /// For a kept pane chain whose usage depends on the panes it retains + /// ([`UsageContext::PaneRetentionOfReachingQueries`]): its usage per + /// retained pane count. Empty otherwise. + pub(crate) usage_per_retained_panes: Vec<(u64, CandidateUsage)>, + /// With `usage_per_retained_panes`: per query that can reach it, the + /// panes it needs retained. + pub(crate) retained_panes_per_query: Vec<(usize, u64)>, + /// Whether some entry of `usage_per_producers` stands for several usages + /// (a producer whose own output depends on its context), of which the + /// costliest is kept. + pub(crate) producer_usage_ambiguous: bool, +} + +impl Default for CandidateData { + fn default() -> Self { + Self { + approximate: false, + accuracy_budget_parts: 1, + usage: Err("not costed".to_string()), + usage_depends_on: Vec::new(), + inputs_changing_usage: Vec::new(), + usage_per_producers: Vec::new(), + usage_per_window: Vec::new(), + output_count: 1, + windows_per_reader: Vec::new(), + usage_per_retained_panes: Vec::new(), + retained_panes_per_query: Vec::new(), + producer_usage_ambiguous: false, + } + } +} + +/// The most producer choices [`CandidateData::usage_per_producers`] lists; beyond +/// it the candidate's usage is marked [`UsageContext::TooManyContexts`] and +/// its first costed usage stands for every choice. +pub(crate) const MAX_PRODUCER_CHOICES: usize = 256; + +/// One candidate's usage, from [`asap_cost::node_usage`] (module +/// documentation). +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct CandidateUsage { + /// `u(k)`: CPU operations of one execution. + pub(crate) cpu_ops_per_execution: f64, + /// `u(k)`: bytes one execution scans. + pub(crate) scanned_bytes_per_execution: f64, + /// Milliseconds of one execution on one core. + pub(crate) work_ms_per_execution: f64, + /// `d_k`: what it adds to a reaching query's latency in + /// [`asap_cost::plan_cost`], its work per execution at query time; 0 + /// at ingestion time, whose latency is the plan-level compaction term. + pub(crate) own_latency_ms: f64, + /// Cost model 1: the cost of one execution; `None` under other models. + pub(crate) cost_per_execution: Option, + /// Cost model 1: the cost per second of what it keeps whatever runs it + /// (kept memory and disk); `None` under other models. + pub(crate) standing_cost_per_second: Option, + /// Memory kept between executions, averaged over time. + pub(crate) average_memory_bytes: f64, + pub(crate) peak_memory_bytes: u64, + pub(crate) disk_bytes: u64, + /// Query-time memory held while an evaluation reading it runs (a + /// `MatAtQuery` state, or a not-materialized node's working memory). + pub(crate) held_bytes: u64, + /// Raw data a query-time scan keeps on disk. + pub(crate) raw_data_kept_bytes: u64, + /// An in-memory ingestion-time state's bytes per window. + pub(crate) maintained_window_bytes: u64, + pub(crate) output_rows: u64, + pub(crate) output_bytes: u64, +} + +/// A candidate's usage and its output (an index below +/// [`CandidateData::output_count`]) in one context. +pub(crate) type UsageAndOutput = (CandidateUsage, usize); + +/// One producer choice for an input: a producer candidate, and for one +/// with several outputs ([`CandidateData::output_count`]), which output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProducerChoice { + pub(crate) class: PhysicalCandidateId, + pub(crate) output: Option, +} + +/// A context option that changed a candidate's usage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum UsageContext { + /// Which producer candidate (of different output) an input reads. + ProducerOutput, + /// Which reader reads a query-time scan, so which window it scans. + ScanWindowOfReader, + /// Which queries reach a kept pane chain, so how many panes it keeps. + PaneRetentionOfReachingQueries, + /// A producer candidate on a cycle of candidate links was not costed yet, so + /// its outputs were not among the options. + ProducerOnACycle, + /// `asap-cost` could not cost some contexts (e.g. a scan read with no + /// window from a source without `rows_held`); the usage is the first + /// costed one's. + UncostableContext, + /// More than [`MAX_CONTEXTS`] combinations: not all were costed. + TooManyContexts, +} + +/// A candidate dominance deleted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DominatedCandidate { + pub(crate) class: PhysicalCandidateId, + pub(crate) dominated_by: PhysicalCandidateId, + pub(crate) reason: String, +} + +/// `w(q, k)`: one share of `q`'s budget, or unusable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum AccuracyWeight { + One, + /// `∞`: `k` is unusable for `q`. + Unusable(UnusableReason), +} + +/// Why a position's weight is `∞`: the check of `q`'s outputs with `k` +/// approximate failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum UnusableReason { + /// Some output has no guarantee + /// ([`RejectionCause::NoAccuracyGuarantee`](crate::report::RejectionCause::NoAccuracyGuarantee)). + NoAccuracyGuarantee, + /// Some output's guarantee misses the target + /// ([`RejectionCause::AccuracyTargetMissed`](crate::report::RejectionCause::AccuracyTargetMissed)). + AccuracyTargetMissed, + /// Not checked, so not usable: more than + /// [`accuracy_positions::MAX_COMPOSITIONS`] outputs to build, or a node + /// that does not rebuild over its producers. + NotChecked, +} + +/// Where a candidate runs (#1095 §2.0, "Memo groups and execution +/// contexts"), by its materialization alone, as `asap_cost::plan_cost` +/// counts its executions: +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum RunKind { + /// `NotMaterialized` at query time: computed once in each execution + /// context (a query evaluation, or a shared query-time candidate's + /// inputs) that selects it. + PerContext, + /// `NotMaterialized` at ingestion time: runs in the ingestion pipeline + /// `I`, once per second of arriving data for every reader. + Ingestion, + /// Materialized (`MatAtIngestion`, `MatAtQuery`, + /// `MatAtQueryKeptAcrossEvaluations`): selected once, its own execution + /// context for its inputs, charged once. + Shareable, +} + +/// The [`RunKind`] of a candidate at `timing` and `materialization`. +pub(crate) fn run_kind(timing: ExecutionTiming, materialization: Materialization) -> RunKind { + match (materialization, timing) { + (Materialization::NotMaterialized, ExecutionTiming::QueryTime) => RunKind::PerContext, + (Materialization::NotMaterialized, ExecutionTiming::IngestionTime) => RunKind::Ingestion, + _ => RunKind::Shareable, + } +} + +/// What "one candidate per memo group per context" (row 2) ranges over: +/// the candidates that give one result. A memo group's implementation +/// roots give the group's result; an implementation's private nodes +/// (`is_implementation_root == false`) give an intermediate result of +/// their own, so each private operator node is its own slot (its +/// candidates differ only in timing and materialization). +/// +/// The memo group graph is not acyclic: a widened scan puts a `Project` in +/// the memo group of the scan it reads, so a group's candidate may read +/// its own group. Such a candidate and its producer cannot share one slot +/// (one context would need both), so a group's roots are split by +/// *self-read depth*: 0 for a root reading no root of its own group (the +/// scan), 1 for one that may (the `Project`). A depth-1 root reading +/// another depth-1 root of its group in one context (a `Project` over a +/// `Project` of the widened scan) is then a read of its own slot, which +/// [`CandidateLinks`] drops for candidates that run in a context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum ResultSlot { + Group(MemoGroupId, u32), + PrivateNode(OperatorNodeOfCandidateForMemoGroupId), +} + +/// The [`ResultSlot`] of every candidate (see there). +pub(crate) fn result_slots(physical: &PhysicalASAPMemoCandidates) -> Vec { + let count = physical.candidates.len(); + let root = |candidate: usize| { + let operator_node = physical.candidates[candidate].operator_node; + let node = &physical.operator_nodes_of_candidates_for_memo_groups[operator_node.0]; + node.is_implementation_root + .then(|| node.group()) + .ok_or(operator_node) + }; + // Self-read depth: 1 for a root that may read a root of its own group. + let depth: Vec = (0..count) + .map(|candidate| { + let Ok(group) = root(candidate) else { + return 0; + }; + let reads_own_group = physical.candidates[candidate] + .producer_candidates_per_input + .iter() + .flat_map(|producers| producers.iter()) + .any(|producer| producer.0 != candidate && root(producer.0) == Ok(group)); + u32::from(reads_own_group) + }) + .collect(); + (0..count) + .map(|candidate| match root(candidate) { + Ok(group) => ResultSlot::Group(group, depth[candidate]), + Err(operator_node) => ResultSlot::PrivateNode(operator_node), + }) + .collect() +} + +/// Per candidate of a [`PhysicalASAPMemoCandidates`]: its producer +/// candidates per input, ascending and without repeats, and its +/// [`RunKind`]. +struct CandidateLinks { + producers_per_input: Vec>>, + kind: Vec, +} + +impl CandidateLinks { + fn new(physical: &PhysicalASAPMemoCandidates) -> Self { + let producers_per_input: Vec>> = physical + .candidates + .iter() + .map(|candidate| { + candidate + .producer_candidates_per_input + .iter() + .map(|producers| { + let set: BTreeSet<_> = producers.iter().copied().collect(); + set.into_iter().collect() + }) + .collect() + }) + .collect(); + let kind: Vec = physical + .candidates + .iter() + .map(|candidate| run_kind(candidate.timing, candidate.materialization)) + .collect(); + // With one candidate per result slot per context, a candidate that + // runs in a context cannot read its own slot there (only a self-loop + // could): such links are dropped (a widened scan's `Project` lists + // its own memo group). A shareable candidate reads in its own + // context, so it keeps them, but never reads itself. + let slot = result_slots(physical); + let producers_per_input = producers_per_input + .into_iter() + .enumerate() + .map(|(reader, inputs): (usize, Vec>)| { + inputs + .into_iter() + .map(|producers| { + producers + .into_iter() + .filter(|producer| { + producer.0 != reader + && (kind[reader] == RunKind::Shareable + || slot[producer.0] != slot[reader]) + }) + .collect() + }) + .collect() + }) + .collect(); + Self { + producers_per_input, + kind, + } + } + + /// Whether `candidate` is materialized: selected once, its own context. + fn is_shareable(&self, candidate: PhysicalCandidateId) -> bool { + self.kind[candidate.0] == RunKind::Shareable + } +} + +/// Per candidate, its reader constraints, each as an id equal for equal +/// constraints once the constrained candidate is left out, ascending and +/// without repeats. +fn reader_constraint_ids_per_candidate(physical: &PhysicalASAPMemoCandidates) -> Vec> { + let mut ids: HashMap = HashMap::new(); + let mut per_candidate = vec![Vec::new(); physical.candidates.len()]; + for constraint in &physical.reader_constraints { + let mut unowned = constraint.clone(); + *unowned.candidate_mut() = PhysicalCandidateId(usize::MAX); + let next = ids.len() as u32; + per_candidate[constraint.candidate().0].push(*ids.entry(unowned).or_insert(next)); + } + for constraint_ids in &mut per_candidate { + constraint_ids.sort_unstable(); + constraint_ids.dedup(); + } + per_candidate +} + +/// [`MilpInputs`] for `physical`'s candidates; `entries[q]` is query `q`'s +/// workload entry. Accuracy is checked with the built-in model and no +/// evidence ([`prepare_milp_inputs_with`]). +#[cfg(test)] +pub(crate) fn prepare_milp_inputs( + physical: &PhysicalASAPMemoCandidates, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + cost_inputs: &CostInputs, +) -> MilpInputs { + let models = SelectionModels { + accuracy: &asap_accuracy::BuiltinAccuracyModel, + evidence: &asap_accuracy::NoAccuracyEvidence, + plan_selection: cost_inputs, + }; + prepare_milp_inputs_with(physical, entries, data, &models) +} + +/// [`MilpInputs`] for `physical`'s candidates, its accuracy positions +/// checked with `models` (the deployment's accuracy model and evidence) and +/// its usage costed with `models.plan_selection`. +pub(crate) fn prepare_milp_inputs_with( + physical: &PhysicalASAPMemoCandidates, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: &SelectionModels<'_>, +) -> MilpInputs { + let cost_inputs = models.plan_selection; + let links = &CandidateLinks::new(physical); + let class_count = physical.candidates.len(); + let all_alive = vec![true; class_count]; + let unpruned_links = producer_candidates_left(links, &all_alive); + let unpruned_outputs = output_candidates(physical, &all_alive); + let unpruned_reach = queries_reaching(physical, &unpruned_links, &unpruned_outputs); + // Every context before pruning, for the windows each scan may be read + // at within one context. + let (all_contexts, _) = execution_contexts( + physical, + &links.kind, + &unpruned_links, + &unpruned_outputs, + |_| true, + ); + let candidate_data = candidate_data( + physical, + links, + entries, + &unpruned_reach, + &all_contexts, + &unpruned_outputs, + data, + cost_inputs, + ); + let pruned_candidates = prune_dominated_candidates( + &dominance_profiles(physical, links, &unpruned_outputs), + &candidate_data, + ); + let mut alive = all_alive; + for pruned in &pruned_candidates { + alive[pruned.class.0] = false; + } + let producer_candidates_per_input = producer_candidates_left(links, &alive); + let output_candidates = output_candidates(physical, &alive); + let kind = links.kind.clone(); + let slot = result_slots(physical); + let usable = |class: PhysicalCandidateId| candidate_data[class.0].usage.is_ok(); + let (contexts, context_of_shareable) = execution_contexts( + physical, + &kind, + &producer_candidates_per_input, + &output_candidates, + usable, + ); + let AccuracyPositions { + weights: accuracy_weights, + excluded_pairs: accuracy_excluded_pairs, + } = accuracy_positions::accuracy_positions( + physical, + &candidate_data, + &producer_candidates_per_input, + &output_candidates, + entries, + data, + models, + ); + MilpInputs { + candidate_data, + pruned_candidates, + producer_candidates_per_input, + output_candidates, + kind, + slot, + contexts, + context_of_shareable, + accuracy_weights, + accuracy_excluded_pairs, + } +} + +/// Per candidate, per input, its producer candidates that are `alive` (none for a +/// candidate that is not). +fn producer_candidates_left( + links: &CandidateLinks, + alive: &[bool], +) -> Vec>> { + links + .producers_per_input + .iter() + .enumerate() + .map(|(class, producers_per_input)| match alive[class] { + false => Vec::new(), + true => producers_per_input + .iter() + .map(|producers| { + producers + .iter() + .copied() + .filter(|producer| alive[producer.0]) + .collect() + }) + .collect(), + }) + .collect() +} + +/// `Out(r)` per output reference: the `alive` candidates among its output +/// candidates, ascending. +fn output_candidates( + physical: &PhysicalASAPMemoCandidates, + alive: &[bool], +) -> Vec> { + (0..physical.output_reference_count()) + .map(|reference| { + let set: BTreeSet<_> = physical + .query_output_candidates(reference) + .filter(|candidate| alive[candidate.0]) + .collect(); + set.into_iter().collect() + }) + .collect() +} + +/// Per candidate, the queries whose output candidates reach it through the links +/// `producer_candidates_per_input`, ascending. +fn queries_reaching( + physical: &PhysicalASAPMemoCandidates, + producer_candidates_per_input: &[Vec>], + output_candidates: &[Vec], +) -> Vec> { + let mut reaching = vec![Vec::new(); producer_candidates_per_input.len()]; + let output_refs = output_references(&physical.query_roots); + for query in 0..physical.query_roots.len() { + let mut seen = vec![false; producer_candidates_per_input.len()]; + let mut stack: Vec = output_refs + .iter() + .enumerate() + .filter(|(_, (q, _))| *q == query) + .flat_map(|(reference, _)| output_candidates[reference].iter().copied()) + .collect(); + while let Some(class) = stack.pop() { + if std::mem::replace(&mut seen[class.0], true) { + continue; + } + reaching[class.0].push(query); + stack.extend( + producer_candidates_per_input[class.0] + .iter() + .flatten() + .copied(), + ); + } + } + reaching +} + +/// Which execution context (#1095 §2.0): a query evaluation, the +/// ingestion pipeline `I`, or a shareable candidate's own inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum ContextKind { + Query(usize), + Ingestion, + Shared(PhysicalCandidateId), +} + +/// One execution context and the candidates that may give a result in it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExecutionContext { + pub(crate) kind: ContextKind, + /// Whether it runs at query time (a query, or a query-time shareable + /// candidate's inputs): its `PerContext` candidates are computed in it. + pub(crate) at_query_time: bool, + /// The candidates that may give a result slot in it, ascending: each + /// computed in it ([`ExecutionContext::computes`]) or read across its + /// boundary (a shareable candidate, or a candidate of `I`). + pub(crate) options: Vec, +} + +impl ExecutionContext { + /// Whether a candidate of `kind` is computed in this context (otherwise + /// it is read across the boundary). + pub(crate) fn computes(&self, kind: RunKind) -> bool { + match kind { + RunKind::PerContext => self.at_query_time, + RunKind::Ingestion => self.kind == ContextKind::Ingestion, + RunKind::Shareable => false, + } + } +} + +/// The execution contexts and their options (module documentation): from +/// each query's output candidates, each shareable candidate's producers +/// and every candidate of `I` read across a boundary, expanded through the +/// inputs of the candidates each context computes. Candidates `usable` +/// rejects (no usage) are left out. A query-time `PerContext` candidate +/// that only an ingestion-time context could read is left out too: it +/// cannot run there. +pub(crate) fn execution_contexts( + physical: &PhysicalASAPMemoCandidates, + kind: &[RunKind], + producer_candidates_per_input: &[Vec>], + output_candidates: &[Vec], + usable: impl Fn(PhysicalCandidateId) -> bool, +) -> (Vec, BTreeMap) { + let query_count = physical.query_roots.len(); + let output_refs = output_references(&physical.query_roots); + // Expands `seeds` in a context: the options and, through the candidates + // it computes, their producers. + let expand = |context: &ExecutionContext, seeds: Vec| { + let mut seen: BTreeSet = BTreeSet::new(); + let mut stack = seeds; + while let Some(class) = stack.pop() { + if !usable(class) || seen.contains(&class) { + continue; + } + let class_kind = kind[class.0]; + if class_kind == RunKind::PerContext && !context.at_query_time { + continue; + } + seen.insert(class); + if context.computes(class_kind) { + stack.extend( + producer_candidates_per_input[class.0] + .iter() + .flatten() + .copied(), + ); + } + } + seen.into_iter().collect::>() + }; + let mut contexts: Vec = Vec::new(); + for query in 0..query_count { + let mut context = ExecutionContext { + kind: ContextKind::Query(query), + at_query_time: true, + options: Vec::new(), + }; + let seeds = output_refs + .iter() + .enumerate() + .filter(|(_, (q, _))| *q == query) + .flat_map(|(reference, _)| output_candidates[reference].iter().copied()) + .collect(); + context.options = expand(&context, seeds); + contexts.push(context); + } + // Shareable contexts, discovered from the options, then `I` from every + // candidate of `I` read across a boundary; repeated until nothing new. + let mut context_of_shareable: BTreeMap = BTreeMap::new(); + let mut shared: Vec = Vec::new(); + let mut ingestion = ExecutionContext { + kind: ContextKind::Ingestion, + at_query_time: false, + options: Vec::new(), + }; + let mut ingestion_seeds: BTreeSet = BTreeSet::new(); + let mut scanned = 0; + loop { + let mut pending: Vec = Vec::new(); + let mut new_ingestion = false; + { + let all = contexts + .iter() + .chain(shared.iter()) + .chain(std::iter::once(&ingestion)); + for (index, context) in all.enumerate() { + if index < scanned && context.kind != ContextKind::Ingestion { + continue; + } + for &class in &context.options { + match kind[class.0] { + RunKind::Shareable => { + if !context_of_shareable.contains_key(&class) + && !pending.contains(&class) + { + pending.push(class); + } + } + RunKind::Ingestion if context.kind != ContextKind::Ingestion => { + new_ingestion |= ingestion_seeds.insert(class); + } + _ => {} + } + } + } + } + scanned = contexts.len() + shared.len(); + if pending.is_empty() && !new_ingestion { + break; + } + pending.sort_unstable(); + for class in pending { + let at_query_time = physical.candidates[class.0].timing == ExecutionTiming::QueryTime; + let mut context = ExecutionContext { + kind: ContextKind::Shared(class), + at_query_time, + options: Vec::new(), + }; + context.options = expand( + &context, + producer_candidates_per_input[class.0] + .iter() + .flatten() + .copied() + .collect(), + ); + context_of_shareable.insert(class, usize::MAX); + shared.push(context); + } + if new_ingestion { + ingestion.options = expand(&ingestion, ingestion_seeds.iter().copied().collect()); + } + } + shared.sort_by_key(|context| context.kind); + contexts.push(ingestion); + for context in shared { + if let ContextKind::Shared(class) = context.kind { + context_of_shareable.insert(class, contexts.len()); + } + contexts.push(context); + } + (contexts, context_of_shareable) +} + +// --------------------------------------------------------------------------- +// Per-candidate data +// --------------------------------------------------------------------------- + +/// Whether `node` outputs approximate values ([`CandidateData::approximate`]). +fn outputs_approximate_values(node: &OperatorNode) -> bool { + matches!( + node.asap(), + Some( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + } | ASAPOp::PaneChain { + family: FieldDataType::Sketch(..), + .. + } + ) + ) +} + +/// The physical-DAG node of `candidate`, with node id `candidate` +/// and each input's child id its first producer candidate (`usize::MAX` when it +/// has none), as Stage 2's realizer would export it before retention. +fn catalog_node( + physical: &PhysicalASAPMemoCandidates, + links: &CandidateLinks, + class: PhysicalCandidateId, +) -> PhysicalASAPDAGNode { + let candidate = &physical.candidates[class.0]; + let node = physical.physical_node(class); + let mut input = 0; + let payload = node.operator.map_children(|_| { + let child = links.producers_per_input[class.0] + .get(input) + .and_then(|producers| producers.first()) + .map_or(usize::MAX, |producer| producer.0); + input += 1; + child + }); + PhysicalASAPDAGNode { + id: class.0, + payload, + output_state: ExecutionDataState { + timing: candidate.timing, + primitive: if node.asap().and_then(ASAPOp::produced_state).is_some() { + DataPrimitive::SummaryState + } else { + DataPrimitive::Raw + }, + }, + materialization: candidate.materialization, + output_schema: node.schema.clone(), + guarantee: node.guarantee.clone(), + retention_ms: None, + } +} + +/// A DAG of `nodes` alone, without edges: what +/// [`asap_cost::pane_retention_ms`] reads (payload children and nodes). +fn nodes_dag(nodes: &[&PhysicalASAPDAGNode]) -> PhysicalASAPDAG { + PhysicalASAPDAG { + nodes: nodes.iter().map(|&node| node.clone()).collect(), + edges: Vec::new(), + roots: Vec::new(), + scalar_roots: Vec::new(), + } +} + +/// The window a scan read through `path` (time shifts, then the reader that +/// ends it) covers, as [`PhysicalASAPDAG::scan_window`] computes it for a +/// plan where `path` is the scan's only reader: the shifts' offsets add up, +/// and a `TimeRange` reads its range, a kept pane chain one pane, any other +/// pane chain its whole window; any other reader reads `Unbounded`. +fn window_read_through(path: &[&PhysicalASAPDAGNode]) -> ScanWindow { + let mut end_offset_ms: i64 = 0; + for node in path { + let width_ms: u128 = match &node.payload { + Operator::NonASAP(NonASAPOp::TimeShift { shift, .. }) if shift.at.is_none() => { + end_offset_ms = end_offset_ms.saturating_add(shift.offset_ms); + continue; + } + Operator::NonASAP(NonASAPOp::TimeRange { range, .. }) => range.as_millis(), + Operator::ASAP(ASAPOp::PaneChain { pane_width_ms, .. }) + if matches!( + node.materialization, + Materialization::MatAtQueryKeptAcrossEvaluations(_) + ) => + { + u128::from(*pane_width_ms) + } + Operator::ASAP(ASAPOp::PaneChain { + pane_width_ms, + pane_count, + .. + }) => u128::from(*pane_width_ms) * u128::from(*pane_count), + _ => return ScanWindow::Unbounded, + }; + return ScanWindow::Within { + end_offset_ms, + width_ms: i64::try_from(width_ms).unwrap_or(i64::MAX), + }; + } + ScanWindow::Unbounded +} + +/// The windows query-time scan `scan` may be read at: each reader's +/// windows (one per path through time shifts) and, per execution context, +/// the unions of the window kinds its readers there read (one scan run read +/// by several readers reads the union, `PhysicalASAPDAG::scan_window`): +/// every union of up to [`MAX_WINDOW_KINDS`] kinds, else the union of all. +/// A read as an output or as a shareable candidate's own input is +/// `Unbounded`. Distinct; `Unbounded` alone when nothing reads it. +fn scan_window_options( + scan: PhysicalCandidateId, + windows_per_reader: &[(PhysicalCandidateId, Vec)], + contexts: &[ExecutionContext], + kind: &[RunKind], + producers_per_input: &[Vec>], + output_candidates: &[Vec], +) -> Vec { + use crate::milp::cost_terms::window_union; + let mut windows: Vec = Vec::new(); + let add = |window: ScanWindow, windows: &mut Vec| { + if !windows.contains(&window) { + windows.push(window); + } + }; + for (_, read) in windows_per_reader { + for &window in read { + add(window, &mut windows); + } + } + let window_of = |reader: PhysicalCandidateId| { + windows_per_reader + .iter() + .find(|(known, _)| *known == reader) + .map_or(ScanWindow::Unbounded, |(_, read)| { + window_union(read.iter().copied()) + }) + }; + let shareable = kind[scan.0] == RunKind::Shareable; + let mut all_kinds: Vec = Vec::new(); + for context in contexts { + if !context.options.contains(&scan) { + continue; + } + let mut kinds: Vec = Vec::new(); + for &reader in &context.options { + if context.computes(kind[reader.0]) + && producers_per_input[reader.0] + .iter() + .any(|producers| producers.contains(&scan)) + { + let window = window_of(reader); + if !kinds.contains(&window) { + kinds.push(window); + } + } + } + let read_whole = match context.kind { + ContextKind::Query(_) => output_candidates.iter().any(|out| out.contains(&scan)), + ContextKind::Shared(own) => producers_per_input[own.0] + .iter() + .any(|producers| producers.contains(&scan)), + ContextKind::Ingestion => false, + }; + if read_whole && !kinds.contains(&ScanWindow::Unbounded) { + kinds.push(ScanWindow::Unbounded); + } + if shareable { + for window in kinds { + if !all_kinds.contains(&window) { + all_kinds.push(window); + } + } + continue; + } + add_unions(&kinds, &mut windows); + } + if shareable { + add_unions(&all_kinds, &mut windows); + } + if windows.is_empty() { + windows.push(ScanWindow::Unbounded); + } + windows +} + +/// Whether window `outer` contains window `inner`. +fn window_contains(outer: ScanWindow, inner: ScanWindow) -> bool { + match (outer, inner) { + (ScanWindow::Unbounded, _) => true, + (ScanWindow::Within { .. }, ScanWindow::Unbounded) => false, + ( + ScanWindow::Within { + end_offset_ms: outer_end, + width_ms: outer_width, + }, + ScanWindow::Within { + end_offset_ms: inner_end, + width_ms: inner_width, + }, + ) => { + outer_end <= inner_end + && outer_end.saturating_add(outer_width) >= inner_end.saturating_add(inner_width) + } + } +} + +/// The most window kinds of one scan run whose every union is costed (and +/// encoded exactly, [`crate::milp::cost_terms`]); beyond it only the union +/// of all. +pub(crate) const MAX_WINDOW_KINDS: usize = 4; + +/// Adds to `windows` every union of a nonempty subset of `kinds` (only the +/// union of all beyond [`MAX_WINDOW_KINDS`]). +fn add_unions(kinds: &[ScanWindow], windows: &mut Vec) { + use crate::milp::cost_terms::scan_window_read_at as window_union; + let mut push = |window: ScanWindow| { + if !windows.contains(&window) { + windows.push(window); + } + }; + if kinds.len() > MAX_WINDOW_KINDS { + push(window_union(kinds.iter().copied())); + return; + } + for set in 1usize..(1 << kinds.len()) { + push(window_union( + (0..kinds.len()) + .filter(|bit| set & (1 << bit) != 0) + .map(|bit| kinds[bit]), + )); + } +} + +/// Per class reading the query-time scan `scan`, the windows it may read it +/// at (through time shifts above it), distinct. +fn scan_windows_per_reader( + scan: PhysicalCandidateId, + nodes: &[PhysicalASAPDAGNode], + readers: &[Vec<(PhysicalCandidateId, usize)>], +) -> Vec<(PhysicalCandidateId, Vec)> { + /// Time shifts nested deeper than this are not followed. + const MAX_SHIFTS: usize = 4; + let mut per_reader: Vec<(PhysicalCandidateId, Vec)> = Vec::new(); + let mut paths: Vec> = vec![vec![scan]]; + while let Some(path) = paths.pop() { + let last = *path.last().expect("never empty"); + let follow = path.len() == 1 + || (matches!( + nodes[last.0].payload, + Operator::NonASAP(NonASAPOp::TimeShift { .. }) + ) && path.len() <= MAX_SHIFTS + && !readers[last.0].is_empty()); + if !follow { + // The path ends at a reader: what the scan reads under it. + let through: Vec<_> = path[1..].iter().map(|class| &nodes[class.0]).collect(); + let window = window_read_through(&through); + let Some(&reader) = path.get(1) else { + continue; + }; + match per_reader.iter_mut().find(|(known, _)| *known == reader) { + Some((_, windows)) if windows.contains(&window) => {} + Some((_, windows)) => windows.push(window), + None => per_reader.push((reader, vec![window])), + } + continue; + } + for &(reader, _) in readers[last.0].iter().rev() { + if !path.contains(&reader) { + let mut longer = path.clone(); + longer.push(reader); + paths.push(longer); + } + } + } + per_reader.sort_by_key(|(reader, _)| *reader); + per_reader +} + +/// The panes kept pane chain `chain` may keep, one per query that can reach +/// it (its range-evaluation span), in `reaching` order; its pane count for +/// any other node. +fn retained_pane_options( + chain: PhysicalCandidateId, + nodes: &[PhysicalASAPDAGNode], + first_producer: &[Option], + reaching: &[usize], + entries: &[QueryWorkloadEntry], +) -> Vec { + let node = &nodes[chain.0]; + let kept = matches!( + (&node.payload, node.materialization), + ( + Operator::ASAP(ASAPOp::PaneChain { .. }), + Materialization::MatAtIngestion(_) + | Materialization::MatAtQueryKeptAcrossEvaluations(_) + ) + ); + if !kept { + return vec![asap_cost::panes_of(node)]; + } + // The chain over the time shifts below it, which set its input offset. + let mut below = Vec::new(); + let mut current = first_producer[chain.0]; + while let Some(producer) = current { + if !matches!( + nodes[producer.0].payload, + Operator::NonASAP(NonASAPOp::TimeShift { .. }) + ) || below.contains(&producer) + { + break; + } + below.push(producer); + current = first_producer[producer.0]; + } + let mut dag_nodes: Vec<&PhysicalASAPDAGNode> = + below.iter().rev().map(|class| &nodes[class.0]).collect(); + dag_nodes.push(node); + let dag = nodes_dag(&dag_nodes); + let mut options = Vec::new(); + let reach = vec![HashSet::from([chain.0])]; + let spans: Vec<_> = match reaching.is_empty() { + true => vec![None], + false => reaching + .iter() + .map(|&query| entries.get(query).and_then(|entry| entry.range_evaluation)) + .collect(), + }; + for range in spans { + let retention = asap_cost::pane_retention_ms(&dag, &reach, &[range]); + let panes = asap_cost::retained_panes(node, &retention); + options.push(panes); + } + options +} + +/// Whether two producer outputs cost their readers the same. +fn same_producer_output(left: &ProducerOutput<'_>, right: &ProducerOutput<'_>) -> bool { + left.output.rows == right.output.rows + && left.output.bytes == right.output.bytes + && left.sources == right.sources + && left.family == right.family + && left.unchanged_columns == right.unchanged_columns + && (std::ptr::eq(left.payload, right.payload) + || left.payload.map_children(|_| 0usize) == right.payload.map_children(|_| 0usize)) + && (std::ptr::eq(left.output_schema, right.output_schema) + || left.output_schema == right.output_schema) +} + +/// `usage` as a reader of `node` sees it. +fn producer_output<'a>(node: &'a PhysicalASAPDAGNode, usage: &NodeUsage<'a>) -> ProducerOutput<'a> { + ProducerOutput { + payload: &node.payload, + output_schema: &node.output_schema, + output: usage.output, + sources: usage.sources.clone(), + family: usage.family, + unchanged_columns: usage.unchanged_columns.clone(), + } +} + +/// Per candidate, every `(reader candidate, input)` that may read it, ascending. +fn readers_of( + producer_candidates_per_input: &[Vec>], +) -> Vec> { + let mut readers = vec![Vec::new(); producer_candidates_per_input.len()]; + for (reader, inputs) in producer_candidates_per_input.iter().enumerate() { + for (input, producers) in inputs.iter().enumerate() { + for producer in producers { + readers[producer.0].push((PhysicalCandidateId(reader), input)); + } + } + } + readers +} + +/// Each candidate's [`CandidateData`] (module documentation, "Usage per execution +/// and its context"). `reaching[k]` are the queries that can reach candidate +/// `k` before pruning. +#[allow(clippy::too_many_arguments)] +fn candidate_data( + physical: &PhysicalASAPMemoCandidates, + links: &CandidateLinks, + entries: &[QueryWorkloadEntry], + reaching: &[Vec], + contexts: &[ExecutionContext], + output_candidates: &[Vec], + data: &DataWorkload, + cost_inputs: &CostInputs, +) -> Vec { + let count = physical.candidates.len(); + let formula_scale = cost_inputs.frozen_calibration_factor.unwrap_or(1.0); + let nodes: Vec = (0..count) + .map(|class| catalog_node(physical, links, PhysicalCandidateId(class))) + .collect(); + let node_by_id: asap_cost::NodeMap<'_> = nodes.iter().map(|node| (node.id, node)).collect(); + let first_producer: Vec> = links + .producers_per_input + .iter() + .map(|producers_per_input| { + producers_per_input + .first() + .and_then(|producers| producers.first().copied()) + }) + .collect(); + let first_producers_of: HashMap> = links + .producers_per_input + .iter() + .enumerate() + .map(|(class, producers_per_input)| { + ( + class, + producers_per_input + .iter() + .filter_map(|producers| producers.first().map(|producer| producer.0)) + .collect(), + ) + }) + .collect(); + let readers = readers_of(&links.producers_per_input); + // Per candidate, the distinct outputs it may give its readers; `None` until + // it is costed. + let mut outputs: Vec>, String>>> = vec![None; count]; + let mut result: Vec = (0..count) + .map(|class| { + let node = physical.physical_node(PhysicalCandidateId(class)); + let operator_node = physical.candidates[class].operator_node; + let accuracy_budget_parts = physical.operator_nodes_of_candidates_for_memo_groups + [operator_node.0] + .accuracy_budget_parts; + CandidateData { + approximate: outputs_approximate_values(node), + accuracy_budget_parts, + usage: Err("not costed".to_string()), + ..CandidateData::default() + } + }) + .collect(); + // Producers first (Kahn's order). Candidate links can close a cycle + // (e.g. a widened scan's `Project` that reads its own memo group): when no candidate + // is ready, the smallest one each of whose inputs has a costed producer + // is costed from those producers alone, and marked + // [`UsageContext::ProducerOnACycle`]. + let producer_sets: Vec> = links + .producers_per_input + .iter() + .map(|producers_per_input| producers_per_input.iter().flatten().map(|p| p.0).collect()) + .collect(); + let mut waiting: Vec = producer_sets.iter().map(BTreeSet::len).collect(); + let mut ready: std::collections::BinaryHeap> = (0..count) + .filter(|&class| waiting[class] == 0) + .map(std::cmp::Reverse) + .collect(); + let mut costed_classes = vec![false; count]; + loop { + let class = match ready.pop() { + Some(std::cmp::Reverse(class)) => class, + None => match (0..count).find(|&class| { + !costed_classes[class] + && links.producers_per_input[class] + .iter() + .all(|producers| producers.iter().any(|p| costed_classes[p.0])) + }) { + Some(class) => class, + None => break, + }, + }; + if std::mem::replace(&mut costed_classes[class], true) { + continue; + } + let mut readers_of_class: BTreeSet = BTreeSet::new(); + for &(reader, _) in &readers[class] { + readers_of_class.insert(reader.0); + } + for reader in readers_of_class { + waiting[reader] -= 1; + if waiting[reader] == 0 && !costed_classes[reader] { + ready.push(std::cmp::Reverse(reader)); + } + } + let class = PhysicalCandidateId(class); + let node = &nodes[class.0]; + let inputs = &links.producers_per_input[class.0]; + // Per input, the distinct outputs of its costed producer candidates. + let mut producer_options: Vec>> = Vec::with_capacity(inputs.len()); + let mut failed = None; + let mut on_a_cycle = false; + // A scan's outputs at windows that do not contain this reader's own + // window are never what it reads (the scan reads at least the + // union of its readers' windows). + let reader = class; + let reads_output = + |result: &[CandidateData], producer: PhysicalCandidateId, at: usize| -> bool { + let windowed = &result[producer.0].usage_per_window; + if windowed.is_empty() { + return true; + } + let Some((_, own)) = result[producer.0] + .windows_per_reader + .iter() + .find(|(known, _)| *known == reader) + else { + return true; + }; + let own = crate::milp::cost_terms::window_union(own.iter().copied()); + // An unbounded read is priced at the other readers' widest + // window (`plan_cost`), whatever it is. + if own == ScanWindow::Unbounded { + return true; + } + windowed + .iter() + .any(|&(window, _, output)| output == at && window_contains(window, own)) + }; + for producers in inputs { + let mut options: Vec> = Vec::new(); + for producer in producers { + match &outputs[producer.0] { + Some(Ok(produced)) => { + for (at, output) in produced.iter().enumerate() { + if !reads_output(&result, *producer, at) { + continue; + } + if !options + .iter() + .any(|known| same_producer_output(known, output)) + { + options.push(output.clone()); + } + } + } + // An uncostable producer is one no plan uses: the + // others still serve this input. + Some(Err(_)) => {} + None => on_a_cycle = true, + } + } + if options.is_empty() { + failed.get_or_insert_with(|| { + format!( + "input {} has no producer with a usage (producers {:?})", + producer_options.len(), + producers.iter().map(|p| p.0).collect::>() + ) + }); + } + producer_options.push(options); + } + if let Some(reason) = failed { + outputs[class.0] = Some(Err(reason.clone())); + result[class.0].usage = Err(reason); + continue; + } + let windows = match (&node.payload, node.output_state.timing) { + (Operator::NonASAP(NonASAPOp::Scan { .. }), ExecutionTiming::QueryTime) => { + result[class.0].windows_per_reader = + scan_windows_per_reader(class, &nodes, &readers); + scan_window_options( + class, + &result[class.0].windows_per_reader, + contexts, + &links.kind, + &links.producers_per_input, + output_candidates, + ) + } + _ => vec![ScanWindow::Unbounded], + }; + let retained_per_query = + retained_pane_options(class, &nodes, &first_producer, &reaching[class.0], entries); + let mut retained: Vec = Vec::new(); + for &panes in &retained_per_query { + if !retained.contains(&panes) { + retained.push(panes); + } + } + if retained.len() > 1 { + result[class.0].retained_panes_per_query = reaching[class.0] + .iter() + .copied() + .zip(retained_per_query.iter().copied()) + .collect(); + } + let ingestion_window_ms = (node.output_state.timing == ExecutionTiming::IngestionTime) + .then(|| asap_cost::maintained_window_ms(class.0, &node_by_id, &first_producers_of)) + .flatten(); + // Every combination of the options. + let combinations = producer_options + .iter() + .map(Vec::len) + .chain([windows.len(), retained.len()]) + .try_fold(1usize, |total, options| total.checked_mul(options)) + .unwrap_or(usize::MAX); + // Each costed combination: its usage, its output, and its picks + // (the option per input, the window and the retained panes). + let mut costed: Vec<(CandidateUsage, ProducerOutput<'_>, Picks)> = Vec::new(); + let mut error = None; + for index in 0..combinations.min(MAX_CONTEXTS) { + let mut rest = index; + let mut pick = |options: usize| { + let picked = rest % options; + rest /= options; + picked + }; + let retained_pick = pick(retained.len()); + let retained_panes = retained[retained_pick]; + let window_pick = pick(windows.len()); + let window = windows[window_pick]; + let mut option_picks: Vec = producer_options + .iter() + .rev() + .map(|options| pick(options.len())) + .collect(); + option_picks.reverse(); + let producers: Vec> = producer_options + .iter() + .zip(&option_picks) + .map(|(options, &picked)| options[picked].clone()) + .collect(); + let usage_at = |per_second: f64| { + asap_cost::node_usage( + node, + &producers, + (1, per_second), + window, + retained_panes, + ingestion_window_ms, + data, + cost_inputs, + formula_scale, + ) + }; + match (usage_at(1.0), usage_at(0.0)) { + (Ok(executed), Ok(standing)) => { + let usage = candidate_usage(&executed, &standing, node.output_state.timing); + costed.push(( + usage, + producer_output(node, &executed), + Picks { + options: option_picks, + window: window_pick, + retained: retained_pick, + }, + )); + } + (Err(e), _) | (_, Err(e)) => { + error.get_or_insert(e.to_string()); + } + } + } + // A context `asap-cost` cannot cost is one no plan can use. + let uncostable_context = error.is_some(); + if costed.is_empty() { + let error = error.unwrap_or_else(|| "no context".into()); + outputs[class.0] = Some(Err(error.clone())); + result[class.0].usage = Err(error); + continue; + } + let first = costed[0].0; + let mut depends_on = Vec::new(); + // Its distinct outputs, and the one each costed combination gives. + let mut produced: Vec> = Vec::new(); + let mut output_of_combination = Vec::with_capacity(costed.len()); + for (_, output, _) in &costed { + let at = match produced + .iter() + .position(|known| same_producer_output(known, output)) + { + Some(at) => at, + None => { + produced.push(output.clone()); + produced.len() - 1 + } + }; + output_of_combination.push(at); + } + // What a combination gives its plan: its usage and its output. + let given: Vec<(CandidateUsage, usize)> = costed + .iter() + .zip(&output_of_combination) + .map(|((usage, _, _), &output)| (*usage, output)) + .collect(); + // The inputs whose option changes the usage or the output, and + // whether the window does: two costed combinations differing only + // there give different usages or outputs. + let changes = |differs: &dyn Fn(&Picks, &Picks) -> bool| { + costed.iter().zip(&given).any(|((_, _, picks), gives)| { + costed + .iter() + .zip(&given) + .any(|((_, _, others), other)| differs(picks, others) && gives != other) + }) + }; + let inputs_changing_usage: Vec = (0..producer_options.len()) + .filter(|&input| { + changes(&|a: &Picks, b: &Picks| { + a.window == b.window + && a.retained == b.retained + && a.options[input] != b.options[input] + && (0..a.options.len()) + .all(|other| other == input || a.options[other] == b.options[other]) + }) + }) + .collect(); + let window_changes_usage = changes(&|a: &Picks, b: &Picks| { + a.window != b.window && a.retained == b.retained && a.options == b.options + }); + if !inputs_changing_usage.is_empty() { + // Per changing input, each producer and the options it gives: + // one choice per output of a producer with several. + let options_of_producers: Vec)>> = + inputs_changing_usage + .iter() + .map(|&input| { + let mut choices = Vec::new(); + for &producer in &inputs[input] { + let Some(Ok(produced)) = &outputs[producer.0] else { + continue; + }; + let option_of = |output: &ProducerOutput<'_>| { + producer_options[input] + .iter() + .position(|known| same_producer_output(known, output)) + }; + if produced.len() == 1 { + choices.push(( + ProducerChoice { + class: producer, + output: None, + }, + produced.iter().filter_map(option_of).collect(), + )); + continue; + } + for (at, output) in produced.iter().enumerate() { + if !reads_output(&result, producer, at) { + continue; + } + choices.push(( + ProducerChoice { + class: producer, + output: Some(at), + }, + option_of(output).into_iter().collect(), + )); + } + } + choices + }) + .collect(); + let choices = options_of_producers + .iter() + .try_fold(1usize, |total, producers| { + total.checked_mul(producers.len()) + }) + .unwrap_or(usize::MAX); + if choices > MAX_PRODUCER_CHOICES { + depends_on.push(UsageContext::TooManyContexts); + } else { + for index in 0..choices { + let mut rest = index; + let chosen: Vec<&(ProducerChoice, Vec)> = options_of_producers + .iter() + .map(|producers| { + let picked = &producers[rest % producers.len()]; + rest /= producers.len(); + picked + }) + .collect(); + let matching: Vec<&(CandidateUsage, usize)> = costed + .iter() + .zip(&given) + .filter(|((_, _, picks), _)| { + inputs_changing_usage.iter().zip(&chosen).all( + |(&input, (_, options))| options.contains(&picks.options[input]), + ) + }) + .map(|(_, gives)| gives) + .collect(); + let costliest = matching.iter().copied().max_by(|a, b| { + let total = |usage: &CandidateUsage| { + usage.cost_per_execution.unwrap_or(0.0) + + usage.standing_cost_per_second.unwrap_or(0.0) + + usage.work_ms_per_execution + }; + total(&a.0).total_cmp(&total(&b.0)) + }); + if matching.iter().any(|gives| Some(*gives) != costliest) { + result[class.0].producer_usage_ambiguous = true; + } + result[class.0].usage_per_producers.push(( + chosen.iter().map(|(choice, _)| *choice).collect(), + costliest.copied(), + )); + } + result[class.0].inputs_changing_usage = inputs_changing_usage; + } + } + if retained.len() > 1 { + let retained_changes_usage = changes(&|a: &Picks, b: &Picks| { + a.retained != b.retained && a.window == b.window && a.options == b.options + }); + if retained_changes_usage { + for (pick, &panes) in retained.iter().enumerate() { + if let Some(at) = costed + .iter() + .position(|(_, _, picks)| picks.retained == pick) + { + result[class.0] + .usage_per_retained_panes + .push((panes, given[at].0)); + } + } + } else { + result[class.0].retained_panes_per_query.clear(); + } + } + // A query-time scan keeps its usage per window even when every + // costed window gives the same: a window `asap-cost` cannot cost + // (an unbounded read with no `rows_held`) must stay unusable. + let query_time_scan = matches!( + (&node.payload, node.output_state.timing), + ( + Operator::NonASAP(NonASAPOp::Scan { .. }), + ExecutionTiming::QueryTime + ) + ); + if window_changes_usage || query_time_scan { + for (pick, window) in windows.iter().enumerate() { + if let Some(at) = costed.iter().position(|(_, _, picks)| picks.window == pick) { + let (usage, output) = given[at]; + result[class.0] + .usage_per_window + .push((*window, usage, output)); + } + } + } + if given.iter().any(|gives| gives.0 != first) { + if producer_options.iter().any(|options| options.len() > 1) { + depends_on.push(UsageContext::ProducerOutput); + } + if windows.len() > 1 { + depends_on.push(UsageContext::ScanWindowOfReader); + } + if retained.len() > 1 { + depends_on.push(UsageContext::PaneRetentionOfReachingQueries); + } + } + if on_a_cycle { + depends_on.push(UsageContext::ProducerOnACycle); + } + if uncostable_context { + depends_on.push(UsageContext::UncostableContext); + } + if combinations > MAX_CONTEXTS { + depends_on.push(UsageContext::TooManyContexts); + } + result[class.0].output_count = produced.len(); + outputs[class.0] = Some(Ok(produced)); + result[class.0].usage = Ok(first); + result[class.0].usage_depends_on = depends_on; + } + for (class, costed) in costed_classes.iter().enumerate() { + if !costed { + result[class].usage = Err("its producer links form a cycle".into()); + } + } + result +} + +/// One costed combination of a class's context options, by index: the +/// option per input, the window and the retained panes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Picks { + options: Vec, + window: usize, + retained: usize, +} + +/// The numbers of one execution (`executed`, at one execution per second) +/// and of none (`standing`) of a node at `timing`. +fn candidate_usage( + executed: &NodeUsage<'_>, + standing: &NodeUsage<'_>, + timing: ExecutionTiming, +) -> CandidateUsage { + let standing_cost = standing.cost.cost_per_second; + CandidateUsage { + cpu_ops_per_execution: executed.cpu_ops_per_second, + scanned_bytes_per_execution: executed.scanned_bytes_per_second, + work_ms_per_execution: executed.work_ms_per_execution, + own_latency_ms: match timing { + ExecutionTiming::QueryTime => executed.work_ms_per_execution, + ExecutionTiming::IngestionTime => 0.0, + }, + cost_per_execution: executed + .cost + .cost_per_second + .zip(standing_cost) + .map(|(total, standing)| total - standing), + standing_cost_per_second: standing_cost, + average_memory_bytes: executed.average_memory_bytes, + peak_memory_bytes: executed.peak_memory_bytes, + disk_bytes: executed.disk_bytes, + held_bytes: executed.held.map_or(0, |(bytes, _, _)| bytes), + raw_data_kept_bytes: executed.raw_scan.map_or(0, |(_, bytes)| bytes), + maintained_window_bytes: executed.maintained.unwrap_or(0), + output_rows: executed.output.rows, + output_bytes: executed.output.bytes, + } +} + +// --------------------------------------------------------------------------- +// Dominance +// --------------------------------------------------------------------------- + +/// What one candidate is compared on (module documentation, "Dominance"). +#[derive(Debug, Clone)] +pub(crate) struct DominanceProfile<'a> { + /// Must be equal for one candidate to dominate another. + pub(crate) same: DominanceKey<'a>, + pub(crate) guarantee: Option<&'a AccuracyGuarantee>, +} + +/// The parts of a [`DominanceProfile`] that must be equal. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DominanceKey<'a> { + pub(crate) group: MemoGroupId, + pub(crate) timing: ExecutionTiming, + pub(crate) materialization: Materialization, + pub(crate) shareable: bool, + pub(crate) producer_candidates_per_input: &'a [Vec], + pub(crate) readers: Vec<(PhysicalCandidateId, usize)>, + pub(crate) output_references: Vec, + pub(crate) reader_constraint_ids: Vec, + pub(crate) may_keep_across_evaluations_per_sole_reader_query: &'a [bool], + pub(crate) output_schema: &'a Schema, + pub(crate) operator_kind: OperatorKindForReaders, +} + +/// The parts of a node's operator its readers' costs read: its kind, and a +/// pane chain's panes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OperatorKindForReaders { + PaneChain { pane_width_ms: u64, pane_count: u32 }, + Other(std::mem::Discriminant, &'static str), +} + +/// Each candidate's [`DominanceProfile`]. `output_candidates` are `Out(r)` before +/// pruning. +fn dominance_profiles<'a>( + physical: &'a PhysicalASAPMemoCandidates, + links: &'a CandidateLinks, + output_candidates: &[Vec], +) -> Vec> { + let constraint_ids = reader_constraint_ids_per_candidate(physical); + let mut readers = readers_of(&links.producers_per_input); + for list in &mut readers { + list.sort_unstable(); + } + let mut output_refs_of = vec![Vec::new(); physical.candidates.len()]; + for (reference, out) in output_candidates.iter().enumerate() { + for class in out { + output_refs_of[class.0].push(reference); + } + } + links + .producers_per_input + .iter() + .enumerate() + .map(|(class, producers_per_input)| { + let copy = PhysicalCandidateId(class); + let candidate = &physical.candidates[copy.0]; + let node = physical.physical_node(copy); + DominanceProfile { + same: DominanceKey { + group: physical.memo_group_of(copy), + timing: candidate.timing, + materialization: candidate.materialization, + shareable: links.is_shareable(copy), + producer_candidates_per_input: producers_per_input, + readers: std::mem::take(&mut readers[class]), + output_references: std::mem::take(&mut output_refs_of[class]), + reader_constraint_ids: constraint_ids[copy.0].clone(), + may_keep_across_evaluations_per_sole_reader_query: &candidate + .may_keep_across_evaluations_per_sole_reader_query, + output_schema: &node.schema, + operator_kind: match &node.operator { + Operator::ASAP(ASAPOp::PaneChain { + pane_width_ms, + pane_count, + .. + }) => OperatorKindForReaders::PaneChain { + pane_width_ms: *pane_width_ms, + pane_count: *pane_count, + }, + operator => OperatorKindForReaders::Other( + std::mem::discriminant(operator), + node.operator.kind_name(), + ), + }, + }, + guarantee: node.guarantee.as_ref(), + } + }) + .collect() +} + +/// Whether `stronger` is at least as strong as `weaker`: equal, or `stronger` +/// exact, or both in one metric and scope with constant bounds and failure +/// probabilities no larger. Anything else (an unknown statistic, different +/// metrics) is not comparable. +pub(crate) fn guarantee_at_least_as_strong( + stronger: Option<&AccuracyGuarantee>, + weaker: Option<&AccuracyGuarantee>, +) -> bool { + match (stronger, weaker) { + (None, None) => true, + (Some(stronger), Some(weaker)) => { + if stronger.spec == weaker.spec || stronger.is_exact() { + return true; + } + stronger.spec.accuracy_metric == weaker.spec.accuracy_metric + && stronger.spec.accuracy_scope == weaker.spec.accuracy_scope + && matches!( + (stronger.spec.error.evaluate(), weaker.spec.error.evaluate()), + (Some(s), Some(w)) if s <= w + ) + && matches!( + ( + stronger.spec.failure_probability.evaluate(), + weaker.spec.failure_probability.evaluate() + ), + (Some(s), Some(w)) if s <= w + ) + } + _ => false, + } +} + +/// Every number of `usage` that a candidate it dominates must not beat, in a +/// fixed order. +fn compared_numbers(usage: &CandidateUsage) -> [f64; 12] { + [ + usage.cpu_ops_per_execution, + usage.scanned_bytes_per_execution, + usage.work_ms_per_execution, + usage.own_latency_ms, + usage.average_memory_bytes, + usage.peak_memory_bytes as f64, + usage.disk_bytes as f64, + usage.held_bytes as f64, + usage.raw_data_kept_bytes as f64, + usage.maintained_window_bytes as f64, + usage.output_rows as f64, + usage.output_bytes as f64, + ] +} + +/// Whether candidate `by` dominates candidate `candidate` (module documentation). +pub(crate) fn dominates( + by: (&DominanceProfile<'_>, &CandidateData), + class: (&DominanceProfile<'_>, &CandidateData), +) -> bool { + let ((by_profile, by_data), (profile, data)) = (by, class); + let (Ok(by_usage), Ok(usage)) = (&by_data.usage, &data.usage) else { + return false; + }; + by_profile.same == profile.same + && by_data.usage_depends_on.is_empty() + && data.usage_depends_on.is_empty() + && by_data.approximate == data.approximate + && by_data.accuracy_budget_parts == data.accuracy_budget_parts + && guarantee_at_least_as_strong(by_profile.guarantee, profile.guarantee) + && compared_numbers(by_usage) + .iter() + .zip(compared_numbers(usage)) + .all(|(by, class)| *by <= class) +} + +/// The candidates another candidate dominates (module documentation), each with +/// the smallest dominating candidate that is kept. Among candidates that dominate +/// each other, the smallest id is kept. +pub(crate) fn prune_dominated_candidates( + profiles: &[DominanceProfile<'_>], + candidate_data: &[CandidateData], +) -> Vec { + // Only candidates with equal keys can dominate each other. + let mut buckets: Vec> = Vec::new(); + let mut bucket_of_key: HashMap<(MemoGroupId, ExecutionTiming, Materialization), Vec> = + HashMap::new(); + for (class, profile) in profiles.iter().enumerate() { + let candidates = bucket_of_key + .entry(( + profile.same.group, + profile.same.timing, + profile.same.materialization, + )) + .or_default(); + match candidates + .iter() + .find(|&&bucket| profiles[buckets[bucket][0]].same == profile.same) + { + Some(&bucket) => buckets[bucket].push(class), + None => { + candidates.push(buckets.len()); + buckets.push(vec![class]); + } + } + } + let mut pruned = Vec::new(); + for bucket in buckets.iter().filter(|bucket| bucket.len() > 1) { + for &class in bucket { + let dominated_by = bucket.iter().copied().find(|&other| { + other != class + && dominates( + (&profiles[other], &candidate_data[other]), + (&profiles[class], &candidate_data[class]), + ) + && (other < class + || !dominates( + (&profiles[class], &candidate_data[class]), + (&profiles[other], &candidate_data[other]), + )) + }); + if let Some(other) = dominated_by { + pruned.push(DominatedCandidate { + class: PhysicalCandidateId(class), + dominated_by: PhysicalCandidateId(other), + reason: format!( + "candidate {other} has the same memo group, producers, readers and \ + (approx, parts), a guarantee at least as strong, and no larger \ + usage, latency, output or held state" + ), + }); + } + } + } + pruned.sort_by_key(|pruned| pruned.class); + pruned +} + +mod accuracy_positions; + +use accuracy_positions::AccuracyPositions; + +#[cfg(test)] +mod tests; diff --git a/crates/plan-selection/src/groups/accuracy_positions.rs b/crates/plan-selection/src/groups/accuracy_positions.rs new file mode 100644 index 000000000..abf0b154e --- /dev/null +++ b/crates/plan-selection/src/groups/accuracy_positions.rs @@ -0,0 +1,567 @@ +//! Accuracy weights and pair exclusions per query (module documentation of +//! [`super`], "Accuracy weights"), each from `asap-accuracy`'s composition +//! and Stage 3's own check ([`guarantee_check`]) of an output built over +//! the candidates. +//! +//! For query `q` and a set `S` of approximate candidates (one candidate, or +//! a pair), the outputs `q` can have with exactly `S` approximate are built +//! from the candidates' operator nodes: a candidate in `S` is its own node; +//! a candidate with no member of `S` below it stands in as its node with an +//! exact guarantee; any other candidate is its node rebuilt over each +//! combination of its producers' outputs, with its guarantee composed again +//! ([`composed_guarantee`]), as the realizer recomposes a selected plan. +//! Outputs over which readers compose the same guarantee are kept once +//! ([`reader_key`]). Each output is then checked as +//! the selected plan is: [`guarantee_check`] with the deployment's model. +//! `S` is usable for `q` only when every output passes: a candidate that +//! fails through one reader is unusable through every reader (the largest +//! weight counts, as before). The candidates of one operator node (other +//! timings or materializations) build the same node, so they are checked +//! once, together. +use std::cell::OnceCell; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; + +use asap_accuracy::propagation::{composed_guarantee, root_guarantee, with_guarantees}; +use asap_types::ir::properties::AccuracyGuarantee; +use asap_types::ir::{OperatorNode, OperatorResultKind, QueryRoot}; +use asap_types::stages::{output_references, PhysicalASAPMemoCandidates, PhysicalCandidateId}; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +use super::{AccuracyWeight, CandidateData, UnusableReason}; +use crate::checks::guarantee_check; +use crate::report::RejectionCause; +use crate::SelectionModels; + +/// The most outputs one candidate (or query) is built as for one set of +/// approximate candidates; beyond it the set is unusable +/// ([`UnusableReason::NotChecked`]). +pub(crate) const MAX_COMPOSITIONS: usize = 64; + +/// What the accuracy rows (#1095 §2.0 row 9) are built from. +#[derive(Debug, Clone, Default)] +pub(crate) struct AccuracyPositions { + /// Per query: `w(q, k)` for each approximate candidate `k` whose values + /// reach `q`'s output. + pub(crate) weights: Vec>, + /// Per query: the pairs `(k1, k2)`, `k1 < k2`, each usable alone, whose + /// outputs miss `q`'s target when both are approximate: + /// `a[q, k1] + a[q, k2] ≤ 1`. + pub(crate) excluded_pairs: Vec>, +} + +/// The outcome of one check of a query with a set of approximate candidates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Outcome { + /// Every output is exact: no value of the set reaches the output. + Exact, + /// Every output meets the target. + Meets, + Fails(UnusableReason), +} + +/// [`AccuracyPositions`] of `physical`'s queries over the candidate links +/// left (`producer_candidates_per_input`, `output_candidates`). +pub(crate) fn accuracy_positions( + physical: &PhysicalASAPMemoCandidates, + candidate_data: &[CandidateData], + producer_candidates_per_input: &[Vec>], + output_candidates: &[Vec], + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: &SelectionModels<'_>, +) -> AccuracyPositions { + let below = approximate_below(candidate_data, producer_candidates_per_input); + let output_refs = output_references(&physical.query_roots); + let exact_stand_ins: Vec>> = (0..physical.candidates.len()) + .map(|_| OnceCell::new()) + .collect(); + let mut positions = AccuracyPositions::default(); + for (query, root) in physical.query_roots.iter().enumerate() { + let references: Vec = (0..output_refs.len()) + .filter(|&reference| output_refs[reference].0 == query) + .collect(); + let reachable: BTreeSet = references + .iter() + .flat_map(|&reference| &output_candidates[reference]) + .flat_map(|class| below[class.0].iter().copied()) + .collect(); + let Some(entry) = entries.get(query) else { + positions.weights.push(BTreeMap::new()); + positions.excluded_pairs.push(Vec::new()); + continue; + }; + let checker = Checker { + physical, + exact_stand_ins: &exact_stand_ins, + producer_candidates_per_input, + output_candidates, + below: &below, + query, + root, + references: &references, + entry, + data, + models, + }; + // Candidates of one operator node (other timings or + // materializations) build the same node: checked once, together. + let mut groups: BTreeMap> = BTreeMap::new(); + for &candidate in &reachable { + groups + .entry(physical.candidates[candidate].operator_node.0) + .or_default() + .push(candidate); + } + let mut weights = BTreeMap::new(); + let mut usable: Vec<&[usize]> = Vec::new(); + for group in groups.values() { + let weight = match checker.check(group) { + Outcome::Exact => continue, + Outcome::Meets => { + usable.push(group); + AccuracyWeight::One + } + Outcome::Fails(reason) => AccuracyWeight::Unusable(reason), + }; + for &candidate in group { + weights.insert(PhysicalCandidateId(candidate), weight); + } + } + let usable_candidates: Vec = usable + .iter() + .flat_map(|group| group.iter().copied()) + .collect(); + let group_of: HashMap = usable + .iter() + .enumerate() + .flat_map(|(index, group)| group.iter().map(move |&candidate| (candidate, index))) + .collect(); + let group_pairs: BTreeSet<(usize, usize)> = pairs_that_meet( + &usable_candidates, + &references, + physical, + producer_candidates_per_input, + output_candidates, + &below, + ) + .into_iter() + .map(|(first, second)| (group_of[&first], group_of[&second])) + .filter(|(first, second)| first != second) + .map(|(first, second)| (first.min(second), first.max(second))) + // A pair with a member sized for fewer than two shares is never + // read together: the shares row already excludes it. + .filter(|&(first, second)| { + [first, second] + .iter() + .all(|&group| candidate_data[usable[group][0]].accuracy_budget_parts >= 2) + }) + .collect(); + let mut excluded = Vec::new(); + for (first, second) in group_pairs { + let both: Vec = usable[first] + .iter() + .chain(usable[second]) + .copied() + .collect(); + if let Outcome::Fails(_) = checker.check(&both) { + for &one in usable[first] { + for &other in usable[second] { + excluded.push(( + PhysicalCandidateId(one.min(other)), + PhysicalCandidateId(one.max(other)), + )); + } + } + } + } + excluded.sort_unstable(); + positions.weights.push(weights); + positions.excluded_pairs.push(excluded); + } + positions +} + +/// Per candidate, the approximate candidates at or below it through the +/// links, ascending. A fixpoint, so cycles of candidate links are covered. +fn approximate_below( + candidate_data: &[CandidateData], + producer_candidates_per_input: &[Vec>], +) -> Vec> { + let mut below: Vec> = candidate_data + .iter() + .enumerate() + .map(|(class, data)| match data.approximate { + true => BTreeSet::from([class]), + false => BTreeSet::new(), + }) + .collect(); + loop { + let mut changed = false; + for class in 0..below.len() { + let mut grown = below[class].clone(); + for producer in producer_candidates_per_input[class].iter().flatten() { + grown.extend(below[producer.0].iter().copied()); + } + if grown.len() != below[class].len() { + below[class] = grown; + changed = true; + } + } + if !changed { + return below; + } + } +} + +/// The pairs of `usable` (ascending, `first < second`) one plan of the +/// query can read together where their values meet: under two different +/// inputs of one candidate, under two output references of a scalar root, +/// or one below the other. +fn pairs_that_meet( + usable: &[usize], + references: &[usize], + physical: &PhysicalASAPMemoCandidates, + producer_candidates_per_input: &[Vec>], + output_candidates: &[Vec], + below: &[BTreeSet], +) -> BTreeSet<(usize, usize)> { + let usable: BTreeSet = usable.iter().copied().collect(); + let mut pairs = BTreeSet::new(); + let mut add_across = |sides: &[BTreeSet]| { + for (index, left) in sides.iter().enumerate() { + for right in &sides[index + 1..] { + for &a in left { + for &b in right { + if a != b { + pairs.insert((a.min(b), a.max(b))); + } + } + } + } + } + }; + let side = |producers: &[PhysicalCandidateId]| -> BTreeSet { + producers + .iter() + .flat_map(|producer| below[producer.0].iter().copied()) + .filter(|candidate| usable.contains(candidate)) + .collect() + }; + for producers_per_input in producer_candidates_per_input + .iter() + .take(physical.candidates.len()) + { + if producers_per_input.len() < 2 { + continue; + } + let sides: Vec> = producers_per_input + .iter() + .map(|producers| side(producers)) + .collect(); + add_across(&sides); + } + let sides: Vec> = references + .iter() + .map(|&reference| side(&output_candidates[reference])) + .collect(); + add_across(&sides); + for &upper in &usable { + for &lower in &below[upper] { + if lower != upper && usable.contains(&lower) { + pairs.insert((lower.min(upper), lower.max(upper))); + } + } + } + pairs +} + +/// One query's checks. +struct Checker<'a> { + physical: &'a PhysicalASAPMemoCandidates, + /// Per candidate, its node with an exact guarantee, built once. + exact_stand_ins: &'a [OnceCell>], + producer_candidates_per_input: &'a [Vec>], + output_candidates: &'a [Vec], + below: &'a [BTreeSet], + query: usize, + root: &'a QueryRoot, + references: &'a [usize], + entry: &'a QueryWorkloadEntry, + data: &'a DataWorkload, + models: &'a SelectionModels<'a>, +} + +/// The outputs one candidate is built as, or why they are not checked. +type Built = Result>, UnusableReason>; + +impl Checker<'_> { + /// Checks the query's outputs with exactly `approximate` approximate. + fn check(&self, approximate: &[usize]) -> Outcome { + let mut memo: HashMap = HashMap::new(); + let mut keys = Keys::new(); + let mut per_reference: Vec>> = Vec::new(); + for &reference in self.references { + let mut outputs: Vec> = Vec::new(); + for &class in &self.output_candidates[reference] { + match self.built(class.0, approximate, &mut memo, &mut keys, &mut Vec::new()) { + Ok(built) => outputs.extend(built), + Err(reason) => return Outcome::Fails(reason), + } + } + per_reference.push(distinct_for_readers(outputs, &mut keys)); + } + let Some(roots) = self.roots(&per_reference) else { + return Outcome::Fails(UnusableReason::NotChecked); + }; + let mut outcome = Outcome::Exact; + for root in roots { + if root_guarantee(&root).is_some_and(|g| g.is_exact()) { + continue; + } + match guarantee_check( + self.query + 1, + &root, + &self.entry.requirements.accuracy, + self.data, + self.models, + ) { + Ok(_) => outcome = Outcome::Meets, + Err(failure) => { + return Outcome::Fails(match failure.cause { + RejectionCause::NoAccuracyGuarantee => UnusableReason::NoAccuracyGuarantee, + _ => UnusableReason::AccuracyTargetMissed, + }) + } + } + } + outcome + } + + /// The query roots over each combination of `per_reference`'s outputs; + /// `None` beyond [`MAX_COMPOSITIONS`]. + fn roots(&self, per_reference: &[Vec>]) -> Option> { + let combinations = combinations(per_reference)?; + Some( + combinations + .into_iter() + .map(|chosen| match self.root { + QueryRoot::Operator(_) => QueryRoot::Operator(chosen[0].clone()), + QueryRoot::Scalar(expr) => { + let mut next = chosen.into_iter(); + QueryRoot::Scalar(expr.map_operator_refs(&mut |_| { + next.next().expect("one output per reference") + })) + } + }) + .collect(), + ) + } + + /// `class`'s node with an exact guarantee, built once. + fn exact_stand_in(&self, class: usize) -> Rc { + self.exact_stand_ins[class] + .get_or_init(|| { + let node = self.physical.physical_node(PhysicalCandidateId(class)); + Rc::new( + (**node) + .clone() + .with_guarantee(Some(AccuracyGuarantee::exact("an exact stand-in"))), + ) + }) + .clone() + } + + /// The outputs `class` is built as with `approximate` approximate + /// (module documentation). `visiting` holds the candidates being built, + /// so a cycle of links builds nothing. + fn built( + &self, + class: usize, + approximate: &[usize], + memo: &mut HashMap, + keys: &mut Keys, + visiting: &mut Vec, + ) -> Built { + if let Some(done) = memo.get(&class) { + return done.clone(); + } + if visiting.contains(&class) { + return Ok(Vec::new()); + } + let node = self.physical.physical_node(PhysicalCandidateId(class)); + let reaches = |candidate: usize| { + approximate + .iter() + .any(|member| self.below[candidate].contains(member)) + }; + let producers = &self.producer_candidates_per_input[class]; + let below_inputs = producers + .iter() + .flatten() + .any(|producer| reaches(producer.0)); + let built = if !reaches(class) { + Ok(vec![self.exact_stand_in(class)]) + } else if !below_inputs { + // `class` is in `approximate`, over exact inputs: its node with + // every guarantee below it composed, as the realizer does. + with_guarantees(node, &mut HashMap::new()) + .map(|node| vec![node]) + .map_err(|_| UnusableReason::NotChecked) + } else { + visiting.push(class); + let mut per_input = Vec::with_capacity(producers.len()); + let mut failed = None; + for input in producers { + let mut outputs = Vec::new(); + for producer in input.iter().filter(|producer| reaches(producer.0)) { + match self.built(producer.0, approximate, memo, keys, visiting) { + Ok(built) => outputs.extend(built), + Err(reason) => failed = Some(reason), + } + } + // One exact stand-in for the producers with no member below: + // one with no approximate candidate below, when there is one. + let exact = input + .iter() + .filter(|producer| !reaches(producer.0)) + .min_by_key(|producer| !self.below[producer.0].is_empty()); + if let Some(exact) = exact { + outputs.push(self.exact_stand_in(exact.0)); + } + per_input.push(distinct_for_readers(outputs, keys)); + } + visiting.pop(); + match failed { + Some(reason) => Err(reason), + None => rebuilt(node, &per_input, keys), + } + }; + memo.insert(class, built.clone()); + built + } +} + +/// `node` rebuilt over each combination of `per_input`'s outputs, with its +/// timing and materialization and its guarantee composed again; outputs the +/// same for their readers kept once. +fn rebuilt(node: &Rc, per_input: &[Vec>], keys: &mut Keys) -> Built { + let combinations = combinations(per_input).ok_or(UnusableReason::NotChecked)?; + let mut outputs = Vec::with_capacity(combinations.len()); + for chosen in combinations { + let mut next = chosen.into_iter(); + let rebuilt = node + .with_new_children(|_| next.next().expect("one output per input")) + .map_err(|_| UnusableReason::NotChecked)?; + let guarantee = composed_guarantee(&rebuilt); + outputs.push(Rc::new( + rebuilt + .with_guarantee(guarantee) + .with_timing(node.timing) + .with_materialization(node.materialization), + )); + } + Ok(distinct_for_readers(outputs, keys)) +} + +/// Every choice of one entry per list, in order; `None` beyond +/// [`MAX_COMPOSITIONS`]. One empty list gives none. +fn combinations(lists: &[Vec]) -> Option>> { + let mut combinations: Vec> = vec![Vec::new()]; + for list in lists { + if combinations.len().saturating_mul(list.len()) > MAX_COMPOSITIONS { + return None; + } + combinations = combinations + .into_iter() + .flat_map(|prefix| { + list.iter().map(move |item| { + let mut next = prefix.clone(); + next.push(item.clone()); + next + }) + }) + .collect(); + } + Some(combinations) +} + +/// `outputs` with outputs the same for their readers kept once (the +/// first): equal [`reader_key`]s. +fn distinct_for_readers(outputs: Vec>, keys: &mut Keys) -> Vec> { + let mut seen = HashSet::new(); + outputs + .into_iter() + .filter(|output| seen.insert(reader_key(output, keys))) + .collect() +} + +/// Per node (by address): its [`reader_key`], and whether binding +/// statistics leaves its guarantee as it is; the node is held so its +/// address is not reused during the check. +type Keys = HashMap<*const OperatorNode, (u64, bool, Rc)>; + +/// A key equal for outputs over which readers compose the same guarantee: +/// an exact node as one key (an exact summary state, a stand-in, by its +/// address); a node with a guarantee (not a summary state) by its bound and failure +/// probability (binding keeps a node's guarantee and ignores its inputs; +/// provenance is not read); a node with none that binding cannot give one +/// as one key; any other node (a summary state, whose readers read its +/// inputs' guarantees, or a node with none that binding may still give +/// one) by its operator kind and its inputs' keys. +fn reader_key(node: &Rc, keys: &mut Keys) -> u64 { + key_and_settled(node, keys).0 +} + +/// [`reader_key`], and whether binding statistics leaves `node`'s +/// guarantee as it is: a guarantee with no unknown statistic, or none over +/// inputs that are all settled. +fn key_and_settled(node: &Rc, keys: &mut Keys) -> (u64, bool) { + if let Some(&(key, settled, _)) = keys.get(&Rc::as_ptr(node)) { + return (key, settled); + } + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + // Exact whatever its inputs (an exact stand-in among them). + if node + .guarantee + .as_ref() + .is_some_and(|guarantee| guarantee.is_exact()) + { + "exact".hash(&mut hasher); + if node.result_kind == OperatorResultKind::State { + Rc::as_ptr(node).hash(&mut hasher); + } + let known = (hasher.finish(), true); + keys.insert(Rc::as_ptr(node), (known.0, known.1, node.clone())); + return known; + } + let spec = node.guarantee.as_ref().map(|guarantee| &guarantee.spec); + format!("{spec:?}").hash(&mut hasher); + let state = node.result_kind == OperatorResultKind::State; + let known = match &node.guarantee { + Some(guarantee) if !state => ( + hasher.finish(), + guarantee.spec.error.evaluate().is_some() + && guarantee.spec.failure_probability.evaluate().is_some(), + ), + _ => { + let children: Vec<(u64, bool)> = node + .children() + .into_iter() + .map(|child| key_and_settled(child, keys)) + .collect(); + let settled = node.guarantee.is_none() && children.iter().all(|(_, settled)| *settled); + if !(settled && !state) { + node.operator.kind_name().hash(&mut hasher); + state.hash(&mut hasher); + for (key, _) in &children { + key.hash(&mut hasher); + } + } + (hasher.finish(), settled) + } + }; + keys.insert(Rc::as_ptr(node), (known.0, known.1, node.clone())); + known +} diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs new file mode 100644 index 000000000..9d6326e8e --- /dev/null +++ b/crates/plan-selection/src/groups/tests.rs @@ -0,0 +1,828 @@ +use std::rc::Rc; + +use super::*; +use asap_logical_asap_optimization::pass2::memo::shared_configurations::PaneWidthDomain; +use asap_types::ir::properties::{AccuracyMetric, BoundExpr, ProbabilityExpr}; +use asap_types::ir::scalar::expr_ir::ScalarValue; +use asap_types::ir::schema::SketchAlgorithm; +use asap_types::ir::schema::{DataType, Field}; +use asap_types::ir::{QueryRoot, ScalarExpr}; +use asap_types::physical::Medium; +use asap_types::stages::{ + CandidateForMemoGroupId, OperatorNodeOfCandidateForMemoGroup, + OperatorNodeOfCandidateForMemoGroupId, PhysicalCandidate, ProducerCandidates, +}; + +// --------------------------------------------------------------------------- +// Hand-built catalogs +// --------------------------------------------------------------------------- + +/// A one-row `VALUES` node of `value`. +fn values_node(value: f64) -> Rc { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Values { + rows: vec![vec![ScalarExpr::Literal(ScalarValue::Float64(value))]], + schema: Schema::lifted(vec![Field::plain("value", DataType::Float64, false)], None), + })) + .unwrap() +} + +/// A `VALUES` node of `rows` one-column rows. +fn rows_node(rows: usize) -> Rc { + let schema = Schema::lifted(vec![Field::plain("value", DataType::Float64, false)], None); + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Values { + rows: vec![vec![ScalarExpr::Literal(ScalarValue::Float64(1.0))]; rows], + schema, + })) + .unwrap() +} + +/// A stateless one-input reader of `child`. +fn reader_of(child: &Rc) -> Rc { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Dedup { + cols: Vec::new(), + child: Rc::clone(child), + })) + .unwrap() +} + +/// A catalog of `nodes`, node `i` in memo group `nodes[i].1` (each node its +/// own candidate of that group, all implementation roots), with +/// `candidates` and one query root per entry of `roots`. +fn catalog( + nodes: Vec<(Rc, u32)>, + candidates: Vec, + roots: Vec>, +) -> PhysicalASAPMemoCandidates { + let mut index_in_group: HashMap = HashMap::new(); + PhysicalASAPMemoCandidates::new( + nodes + .into_iter() + .map(|(physical_node, group)| { + let index = index_in_group.entry(group).or_default(); + *index += 1; + OperatorNodeOfCandidateForMemoGroup { + candidates_for_memo_group: vec![CandidateForMemoGroupId { + group: MemoGroupId(group), + index: *index - 1, + }], + is_implementation_root: true, + accuracy_budget_parts: 1, + physical_node, + } + }) + .collect(), + candidates, + roots, + Vec::new(), + ) +} + +/// One candidate of operator node `node` at query time, not materialized, +/// reading `producers` per input, that may produce a query's output when +/// `output`. +fn candidate(node: usize, producers: &[&[usize]], output: bool) -> PhysicalCandidate { + PhysicalCandidate { + operator_node: OperatorNodeOfCandidateForMemoGroupId(node), + timing: ExecutionTiming::QueryTime, + materialization: Materialization::NotMaterialized, + producer_candidates_per_input: producers + .iter() + .map(|input| { + input + .iter() + .map(|&id| PhysicalCandidateId(id)) + .collect::() + }) + .collect(), + may_produce_query_output: output, + may_keep_across_evaluations_per_sole_reader_query: Vec::new(), + } +} + +fn root(group: u32) -> QueryRoot { + QueryRoot::Operator(MemoGroupId(group)) +} + +fn prepared(physical: &PhysicalASAPMemoCandidates) -> MilpInputs { + prepare_milp_inputs( + physical, + &[], + &DataWorkload::default(), + &CostInputs::default(), + ) +} + +fn id(candidate: usize) -> PhysicalCandidateId { + PhysicalCandidateId(candidate) +} + +// --------------------------------------------------------------------------- +// Dominance +// --------------------------------------------------------------------------- + +fn usage() -> CandidateUsage { + CandidateUsage { + cpu_ops_per_execution: 10.0, + scanned_bytes_per_execution: 10.0, + work_ms_per_execution: 10.0, + own_latency_ms: 10.0, + cost_per_execution: Some(1.0), + standing_cost_per_second: Some(1.0), + average_memory_bytes: 10.0, + peak_memory_bytes: 10, + disk_bytes: 10, + held_bytes: 10, + raw_data_kept_bytes: 10, + maintained_window_bytes: 10, + output_rows: 10, + output_bytes: 10, + } +} + +fn data_with(usage: CandidateUsage) -> CandidateData { + CandidateData { + approximate: true, + accuracy_budget_parts: 1, + usage: Ok(usage), + ..CandidateData::default() + } +} + +fn relative(error: f64, failure_probability: f64) -> AccuracyGuarantee { + AccuracyGuarantee::new( + AccuracyMetric::Relative, + BoundExpr::Constant { value: error }, + ProbabilityExpr::Constant { + value: failure_probability, + }, + Vec::new(), + ) +} + +// Candidate dominance needs every condition: a candidate with equal keys, a +// guarantee at least as strong and no larger number dominates; changing any one condition stops it. +#[test] +fn dominance_needs_every_condition() { + let producers = vec![vec![PhysicalCandidateId(7)]]; + let other_producers = vec![vec![PhysicalCandidateId(8)]]; + let keep = vec![false]; + let other_keep = vec![true]; + let schema = rows_node(1).schema.clone(); + let other_schema = Schema::lifted(vec![Field::plain("v", DataType::Int64, false)], None); + let strong = relative(0.01, 0.01); + let weak = relative(0.02, 0.01); + let values_kind = + OperatorKindForReaders::Other(std::mem::discriminant(&rows_node(1).operator), "Values"); + let key = DominanceKey { + group: MemoGroupId(3), + timing: ExecutionTiming::QueryTime, + materialization: Materialization::NotMaterialized, + shareable: false, + producer_candidates_per_input: &producers, + readers: vec![(PhysicalCandidateId(9), 0)], + output_references: vec![0], + reader_constraint_ids: vec![1], + may_keep_across_evaluations_per_sole_reader_query: &keep, + output_schema: &schema, + operator_kind: values_kind, + }; + let profile = |guarantee| DominanceProfile { + same: key.clone(), + guarantee, + }; + let better = profile(Some(&strong)); + let worse = profile(Some(&weak)); + let mut worse_usage = usage(); + worse_usage.cpu_ops_per_execution = 20.0; + let (better_data, worse_data) = (data_with(usage()), data_with(worse_usage)); + assert!(dominates((&better, &better_data), (&worse, &worse_data))); + assert!(!dominates((&worse, &worse_data), (&better, &better_data))); + + // Each key field. + let changed_keys = [ + ( + "group", + DominanceKey { + group: MemoGroupId(4), + ..key.clone() + }, + ), + ( + "timing", + DominanceKey { + timing: ExecutionTiming::IngestionTime, + ..key.clone() + }, + ), + ( + "materialization", + DominanceKey { + materialization: Materialization::MatAtQuery(Medium::Memory), + ..key.clone() + }, + ), + ( + "shareable", + DominanceKey { + shareable: true, + ..key.clone() + }, + ), + ( + "producers", + DominanceKey { + producer_candidates_per_input: &other_producers, + ..key.clone() + }, + ), + ( + "readers", + DominanceKey { + readers: vec![(PhysicalCandidateId(10), 0)], + ..key.clone() + }, + ), + ( + "outputs", + DominanceKey { + output_references: vec![1], + ..key.clone() + }, + ), + ( + "reader constraints", + DominanceKey { + reader_constraint_ids: vec![2], + ..key.clone() + }, + ), + ( + "keep flags", + DominanceKey { + may_keep_across_evaluations_per_sole_reader_query: &other_keep, + ..key.clone() + }, + ), + ( + "schema", + DominanceKey { + output_schema: &other_schema, + ..key.clone() + }, + ), + ( + "operator kind", + DominanceKey { + operator_kind: OperatorKindForReaders::PaneChain { + pane_width_ms: 60_000, + pane_count: 5, + }, + ..key.clone() + }, + ), + ]; + for (name, changed) in changed_keys { + let by = DominanceProfile { + same: changed, + ..better.clone() + }; + assert!( + !dominates((&by, &better_data), (&worse, &worse_data)), + "{name}" + ); + } + // (approx, parts), guarantee. + let mut data = better_data.clone(); + data.approximate = false; + assert!( + !dominates((&better, &data), (&worse, &worse_data)), + "approx" + ); + let mut data = better_data.clone(); + data.accuracy_budget_parts = 2; + assert!(!dominates((&better, &data), (&worse, &worse_data)), "parts"); + let by = profile(Some(&weak)); + let class = profile(Some(&strong)); + assert!( + !dominates((&by, &better_data), (&class, &worse_data)), + "guarantee" + ); + // Each number. + for number in 0..compared_numbers(&usage()).len() { + let mut larger = usage(); + match number { + 0 => larger.cpu_ops_per_execution = 30.0, + 1 => larger.scanned_bytes_per_execution = 30.0, + 2 => larger.work_ms_per_execution = 30.0, + 3 => larger.own_latency_ms = 30.0, + 4 => larger.average_memory_bytes = 30.0, + 5 => larger.peak_memory_bytes = 30, + 6 => larger.disk_bytes = 30, + 7 => larger.held_bytes = 30, + 8 => larger.raw_data_kept_bytes = 30, + 9 => larger.maintained_window_bytes = 30, + 10 => larger.output_rows = 30, + _ => larger.output_bytes = 30, + } + assert!( + !dominates((&better, &data_with(larger)), (&worse, &worse_data)), + "number {number}" + ); + } + // A plan-dependent or uncosted usage is never compared. + let mut data = better_data.clone(); + data.usage_depends_on = vec![UsageContext::ProducerOutput]; + assert!( + !dominates((&better, &data), (&worse, &worse_data)), + "context" + ); + let mut data = better_data.clone(); + data.usage = Err("uncosted".into()); + assert!(!dominates((&better, &data), (&worse, &worse_data)), "error"); +} + +// A guarantee is at least as strong when equal, exact, or no larger in one +// metric and scope; different metrics and unknown bounds do not compare. +#[test] +fn guarantee_strength_compares_constant_bounds_in_one_metric() { + let strong = relative(0.01, 0.01); + let weak = relative(0.02, 0.02); + let exact = AccuracyGuarantee::exact("exact"); + let rank = AccuracyGuarantee::new( + AccuracyMetric::Rank, + BoundExpr::Constant { value: 0.001 }, + ProbabilityExpr::Zero, + Vec::new(), + ); + assert!(guarantee_at_least_as_strong(Some(&strong), Some(&weak))); + assert!(!guarantee_at_least_as_strong(Some(&weak), Some(&strong))); + assert!(guarantee_at_least_as_strong(Some(&exact), Some(&weak))); + assert!(guarantee_at_least_as_strong(None, None)); + assert!(!guarantee_at_least_as_strong(Some(&strong), None)); + assert!(!guarantee_at_least_as_strong(Some(&rank), Some(&weak))); +} + +// Among dominated candidates the dominating one is reported; of two equal +// candidates the smaller id is kept; a candidate better in one number and worse in +// another is kept. +#[test] +fn pruning_keeps_the_smallest_equal_class_and_incomparable_ones() { + let producers = vec![vec![PhysicalCandidateId(9)]]; + let keep = Vec::new(); + let schema = rows_node(1).schema.clone(); + let key = DominanceKey { + group: MemoGroupId(0), + timing: ExecutionTiming::QueryTime, + materialization: Materialization::NotMaterialized, + shareable: false, + producer_candidates_per_input: &producers, + readers: Vec::new(), + output_references: vec![0], + reader_constraint_ids: Vec::new(), + may_keep_across_evaluations_per_sole_reader_query: &keep, + output_schema: &schema, + operator_kind: OperatorKindForReaders::Other( + std::mem::discriminant(&rows_node(1).operator), + "Values", + ), + }; + let profiles = vec![ + DominanceProfile { + same: key.clone(), + guarantee: None, + }; + 4 + ]; + let mut worse = usage(); + worse.output_rows = 20; + let mut incomparable = usage(); + incomparable.cpu_ops_per_execution = 5.0; + incomparable.held_bytes = 20; + let data = vec![ + data_with(usage()), + data_with(usage()), + data_with(worse), + data_with(incomparable), + ]; + let pruned = prune_dominated_candidates(&profiles, &data); + let summary: Vec<_> = pruned + .iter() + .map(|pruned| (pruned.class.0, pruned.dominated_by.0)) + .collect(); + assert_eq!(summary, vec![(1, 0), (2, 0)]); +} + +// Through `prepare_milp_inputs`: of two deduplications in one memo group +// over one leaf, read by one reader and costing the same, the later one is +// deleted with its links, and the reader reads the other. +#[test] +fn a_dominated_class_is_deleted_with_its_links() { + let schema = Schema::lifted( + vec![ + Field::plain("a", DataType::Float64, false), + Field::plain("b", DataType::Float64, false), + ], + None, + ); + let leaf = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Values { + rows: vec![vec![ScalarExpr::Literal(ScalarValue::Float64(1.0)); 2]; 4], + schema, + })) + .unwrap(); + let dedup = |cols: Vec| { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Dedup { + cols, + child: Rc::clone(&leaf), + })) + .unwrap() + }; + // Two equal nodes: equal schemas and costs. + let (first, second) = (dedup(vec![0]), dedup(vec![0])); + let reader = reader_of(&first); + let physical = catalog( + vec![(leaf, 0), (first, 1), (second, 1), (reader, 2)], + vec![ + candidate(0, &[], false), + candidate(1, &[&[0]], false), + candidate(2, &[&[0]], false), + candidate(3, &[&[1, 2]], true), + ], + vec![root(2)], + ); + let inputs = prepared(&physical); + let (leaf, kept, deleted, top) = (id(0), id(1), id(2), id(3)); + assert_eq!( + inputs.candidate_data[kept.0].usage, + inputs.candidate_data[deleted.0].usage + ); + assert_eq!( + inputs + .pruned_candidates + .iter() + .map(|pruned| (pruned.class, pruned.dominated_by)) + .collect::>(), + vec![(deleted, kept)] + ); + assert_eq!( + inputs.producer_candidates_per_input[top.0], + vec![vec![kept]] + ); + assert!(inputs.producer_candidates_per_input[deleted.0].is_empty()); + assert_eq!(inputs.contexts[0].options, vec![leaf, kept, top]); +} + +// --------------------------------------------------------------------------- +// Execution contexts +// --------------------------------------------------------------------------- + +/// A leaf, a materialized (shareable) middle, and two queries each reading +/// the middle through its own stateless top; a third query reads only its +/// own leaf. Candidate `i` runs operator node `i`. +fn shared_middle() -> PhysicalASAPMemoCandidates { + let leaf = values_node(1.0); + let middle = reader_of(&leaf); + let top_0 = reader_of(&middle); + let top_1 = reader_of(&middle); + let lone = values_node(2.0); + let candidates = vec![ + candidate(0, &[], false), + PhysicalCandidate { + materialization: Materialization::MatAtQuery(Medium::Memory), + ..candidate(1, &[&[0]], false) + }, + candidate(2, &[&[1]], true), + candidate(3, &[&[1]], true), + candidate(4, &[], true), + ]; + catalog( + vec![(leaf, 0), (middle, 1), (top_0, 2), (top_1, 3), (lone, 4)], + candidates, + vec![root(2), root(3), root(4)], + ) +} + +// Each query context holds its own top and the shared middle as a read +// across its boundary; the middle's own context holds the leaf below it; +// `I` holds nothing; a query that cannot reach the middle holds only its +// own candidate. +#[test] +fn execution_contexts_on_a_hand_built_catalog() { + let physical = shared_middle(); + let inputs = prepared(&physical); + let (leaf, middle, top_0, top_1, lone) = (id(0), id(1), id(2), id(3), id(4)); + assert!(CandidateLinks::new(&physical).is_shareable(middle)); + assert!(inputs.pruned_candidates.is_empty()); + let contexts: Vec<(ContextKind, Vec)> = inputs + .contexts + .iter() + .map(|context| (context.kind, context.options.clone())) + .collect(); + assert_eq!( + contexts, + vec![ + (ContextKind::Query(0), vec![middle, top_0]), + (ContextKind::Query(1), vec![middle, top_1]), + (ContextKind::Query(2), vec![lone]), + (ContextKind::Ingestion, Vec::new()), + (ContextKind::Shared(middle), vec![leaf]), + ] + ); + assert_eq!(inputs.context_of_shareable, BTreeMap::from([(middle, 4)])); + assert!(inputs.contexts[4].computes(RunKind::PerContext)); + assert!(!inputs.contexts[4].computes(RunKind::Shareable)); + assert_eq!( + inputs.output_candidates, + vec![vec![top_0], vec![top_1], vec![lone]] + ); +} + +/// Stage 2's output for `queries` and what Stage 3 also reads. +struct ThroughStages1And2 { + physical: PhysicalASAPMemoCandidates, + entries: Vec, + data: DataWorkload, +} + +/// `queries` (PromQL over metric `m`, each predictable and repeating every +/// minute with a 1 % error target, over metric `m` with 100 series) +/// through Stage 1 and Stage 2 as the facade runs them, over the complete pane-width domain, with raw data and +/// query-time results retained. +fn through_stages_1_and_2(queries: &[&str]) -> ThroughStages1And2 { + through_stages_1_and_2_over(queries, PaneWidthDomain::Complete) +} + +/// [`through_stages_1_and_2`] over the pane-width domain `domain`. +fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> ThroughStages1And2 { + use asap_logical_asap_optimization::pass1::logical_memo_candidates::generate_per_computation_logical_memo_candidates; + use asap_logical_asap_optimization::pass2::logical_memo_candidates::{ + generate_logical_asap_memo_candidates, Pass2RoundState, + }; + use asap_logical_asap_optimization::pass2::memo::DagOfLogicalMemoGroups; + use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_types::ir::properties::AccuracyTarget; + use asap_types::workload::{ + DurationMs, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, + }; + let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); + let roots: Vec = queries + .iter() + .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) + .collect(); + let every_minute = + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))); + let entries: Vec<_> = queries + .iter() + .map(|_| QueryWorkloadEntry { + predictability: Predictability::Predictable { known_at: None }, + ..entry_with(target.clone(), every_minute.clone()) + }) + .collect(); + let mut data = DataWorkload::sampled_every(["m".to_string()], DurationMs(1_000)); + let source = data.sources.get_mut("m").expect("m"); + source.series_count = Some(100); + source.values_non_negative = true; + let targets = vec![target.clone(); queries.len()]; + let pass1 = generate_per_computation_logical_memo_candidates( + DagOfLogicalMemoGroups::from_roots(&roots), + &targets, + &asap_accuracy::BuiltinAccuracyModel, + &asap_accuracy::NoAccuracyEvidence, + &data, + None, + None, + ) + .unwrap(); + let mut round = Pass2RoundState::new(pass1); + let logical = + generate_logical_asap_memo_candidates(&mut round, &entries, domain, None).unwrap(); + let physical = asap_physical_asap_optimization::generate_physical_asap_memo_candidates( + &logical, &entries, &data, true, true, None, + ) + .unwrap(); + // Stage 2 copies each node's `accuracy_budget_parts` from its memo + // candidates, the largest for a factored node. + for node in &physical.operator_nodes_of_candidates_for_memo_groups { + let largest = node + .candidates_for_memo_group + .iter() + .map(|id| logical.memo.candidates[id.group.0 as usize][id.index].accuracy_budget_parts) + .max(); + assert_eq!(Some(node.accuracy_budget_parts), largest); + } + ThroughStages1And2 { + physical, + entries, + data, + } +} + +// Through the real Stages 1 and 2: two quantile queries over 1m and 5m +// windows of one metric can share a pane chain. Both queries reach it, it +// is an option of both query contexts with a context of its own, and every +// context computes only what runs there. +// No quotient: every approximate candidate weighs 1. +#[test] +fn two_queries_share_a_pane_chain_through_the_real_pipeline() { + let stages = through_stages_1_and_2(&[ + "quantile_over_time(0.9, m[15m])", + "quantile_over_time(0.9, m[1h])", + ]); + let physical = &stages.physical; + let links = CandidateLinks::new(physical); + let inputs = prepare_milp_inputs( + physical, + &stages.entries, + &stages.data, + &CostInputs::default(), + ); + let shared_chains: Vec = (0..physical.candidates.len()) + .map(PhysicalCandidateId) + .filter(|candidate| { + matches!( + physical.physical_node(*candidate).asap(), + Some(ASAPOp::PaneChain { .. }) + ) && links.is_shareable(*candidate) + && inputs.contexts[0].options.contains(candidate) + && inputs.contexts[1].options.contains(candidate) + }) + .collect(); + assert!(!shared_chains.is_empty()); + for &chain in &shared_chains { + let context = inputs.context_of_shareable[&chain]; + assert_eq!(inputs.contexts[context].kind, ContextKind::Shared(chain)); + } + // Every context computes only candidates of its own kind and reads the + // others across its boundary. + for context in &inputs.contexts { + for &option in &context.options { + let kind = inputs.kind[option.0]; + assert!( + context.computes(kind) + || kind == RunKind::Shareable + || (kind == RunKind::Ingestion && context.kind != ContextKind::Ingestion) + ); + } + } + for weights in &inputs.accuracy_weights { + assert!(!weights.is_empty()); + assert!( + weights + .values() + .all(|&weight| weight == AccuracyWeight::One), + "{weights:?}" + ); + } +} + +// --------------------------------------------------------------------------- +// Accuracy weights +// --------------------------------------------------------------------------- + +/// The sketch algorithm and `parts` of each approximate candidate of +/// `physical`, with its weight for query 0 (`None`: no weight). +fn sketch_weights( + physical: &PhysicalASAPMemoCandidates, + inputs: &MilpInputs, +) -> Vec<(SketchAlgorithm, usize, Option)> { + (0..physical.candidates.len()) + .filter(|&class| inputs.candidate_data[class].approximate) + .map(|class| { + let id = PhysicalCandidateId(class); + let algorithm = match physical.physical_node(id).asap() { + Some( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + } + | ASAPOp::PaneChain { + family: FieldDataType::Sketch(kind, _), + .. + }, + ) => *kind.algorithm(), + _ => unreachable!("an approximate candidate is a sketch"), + }; + ( + algorithm, + inputs.candidate_data[class].accuracy_budget_parts, + inputs.accuracy_weights[0].get(&id).copied(), + ) + }) + .collect() +} + +// `q0.5 / q0.9` through the real stages, weights from the composition: a +// KLL rank error has no rule through a quotient, so Pass 1 prunes every KLL +// candidate (#1129); a DDSketch relative +// error composes, but a lone denominator sized for the whole target +// (`parts = 1`) misses it (`ε/(1 − ε) > ε`) while one sized for two parts +// meets it. +#[test] +fn ratio_weights_come_from_the_composition() { + let stages = through_stages_1_and_2(&[ + "quantile_over_time(0.5, m[5m]) / quantile_over_time(0.9, m[5m])", + ]); + let inputs = prepare_milp_inputs( + &stages.physical, + &stages.entries, + &stages.data, + &CostInputs::default(), + ); + let weights = sketch_weights(&stages.physical, &inputs); + let of = |algorithm: SketchAlgorithm, parts: usize| -> BTreeSet> { + weights + .iter() + .filter(|(a, p, _)| *a == algorithm && *p == parts) + .map(|(_, _, weight)| *weight) + .collect() + }; + let missed = Some(AccuracyWeight::Unusable( + UnusableReason::AccuracyTargetMissed, + )); + // Pass 1 prunes every KLL: its rank error composes no guarantee + // through the quotient. + for parts in [1, 2] { + assert_eq!( + of(SketchAlgorithm::Kll, parts), + BTreeSet::new(), + "{weights:?}" + ); + } + // At one part: numerators meet the target, denominators miss it. + assert_eq!( + of(SketchAlgorithm::DDSketch, 1), + BTreeSet::from([Some(AccuracyWeight::One), missed]), + "{weights:?}" + ); + assert_eq!( + of(SketchAlgorithm::DDSketch, 2), + BTreeSet::from([Some(AccuracyWeight::One)]), + "{weights:?}" + ); + // Two DDSketches at two parts compose: no pair is excluded. + assert!(inputs.accuracy_excluded_pairs[0].is_empty()); +} + +// RangeHeavy T8+T5's shape: `q0.9 / q0.5` with both operands also queried +// alone. One DDSketch pane chain sized for two shares of the budget serves +// both quotient operands and both lone quantiles, as today's engine plans +// it: the check accepts the quotient over it (`(α + α)/(1 − α) ≤ ε` at +// `α = 1 − (1 + ε)^{−1/2}`), so it weighs 1 for every query. A chain sized +// for the whole budget (`α = ε`) never serves the quotient's denominator. +#[test] +fn one_pane_chain_serves_a_ratio_and_its_operands() { + let stages = through_stages_1_and_2_over( + &[ + "quantile_over_time(0.9, m[5m]) / quantile_over_time(0.5, m[5m])", + "quantile_over_time(0.9, m[5m])", + "quantile_over_time(0.5, m[5m])", + ], + PaneWidthDomain::SeedGrids, + ); + let inputs = prepare_milp_inputs( + &stages.physical, + &stages.entries, + &stages.data, + &CostInputs::default(), + ); + let alpha = |class: usize| match stages + .physical + .physical_node(PhysicalCandidateId(class)) + .asap() + { + Some(ASAPOp::PaneChain { + family: FieldDataType::Sketch(kind, _), + .. + }) => match kind.params() { + asap_types::ir::schema::SketchParams::DDSketch { alpha } => Some(*alpha), + _ => None, + }, + _ => None, + }; + let weight = |query: usize, class: usize| { + inputs.accuracy_weights[query] + .get(&PhysicalCandidateId(class)) + .copied() + }; + let two_shares = 1.0 - 1.01f64.powf(-0.5); + let shared: Vec = (0..stages.physical.candidates.len()) + .filter(|&class| alpha(class).is_some()) + .filter(|&class| (0..3).all(|query| weight(query, class) == Some(AccuracyWeight::One))) + .collect(); + assert!(!shared.is_empty()); + for &class in &shared { + assert!( + (alpha(class).unwrap() - two_shares).abs() < 1e-9, + "{:?}", + alpha(class) + ); + } + // A chain sized for the whole budget weighs 1 for the ratio only where + // its values are the numerator's alone, so it never also serves both + // lone quantiles. + for class in (0..stages.physical.candidates.len()).filter(|&class| alpha(class) == Some(0.01)) { + assert!( + !(0..3).all(|query| weight(query, class) == Some(AccuracyWeight::One)), + "{class}" + ); + } +} diff --git a/crates/plan-selection/src/lib.rs b/crates/plan-selection/src/lib.rs index 3a0c858e0..04c7f6c05 100644 --- a/crates/plan-selection/src/lib.rs +++ b/crates/plan-selection/src/lib.rs @@ -247,16 +247,23 @@ use asap_cost::state_bytes::{sketch_shape, summary_shape, StateInputRows}; mod accuracy_binding; mod checks; mod models; +// #1095 §2.0: the group selection MILP's inputs (dominance, execution +// contexts, accuracy weights). +mod groups; +// #1095 §2.0: the group selection MILP and the decode of its solution. +mod milp; mod report; mod search; +// #1095 C5: the new search behind `select_plan`'s internal switch. +mod select; mod selector; pub(crate) use accuracy_binding::bound_root_guarantee; pub(crate) use checks::{accuracy_targets, guarantee_check}; pub use models::AccuracyCheckInputs; pub(crate) use models::SelectionModels; pub use report::{ - large_peak_memory_warning, AcceptedAccuracy, ErrorSelectingPlan, RejectionCause, SearchReport, - SearchStatus, Selection, + large_peak_memory_warning, AcceptedAccuracy, ErrorSelectingPlan, InexactObjective, + RejectionCause, SearchReport, SearchStatus, Selection, }; pub(crate) use report::{Rejection, WholePlanSelection}; pub use search::select::select_plan; diff --git a/crates/plan-selection/src/milp/completion.rs b/crates/plan-selection/src/milp/completion.rs new file mode 100644 index 000000000..86bcc1f44 --- /dev/null +++ b/crates/plan-selection/src/milp/completion.rs @@ -0,0 +1,306 @@ +//! The cost of a plan given as values of the MILP's binaries: the cheapest +//! values of the continuous variables, found without a solver. +//! +//! Every continuous variable of the model is one of: +//! - a lower bound that minimization makes tight: `v ≥ f(others)` over one or +//! more rows (reach `a`, latency `L`, held time `G`/`H`/`W`, AND variables, +//! rate components, raw-retention levels, …). Its cheapest value is the +//! largest right-hand side, or 0; +//! - an equality `N = Σ a` (approximate values read); +//! - free below its upper rows only (`t`, reach through): its value is the +//! smallest upper bound, which leaves the most room to the rows it +//! relaxes. +//! +//! A row defines the continuous variable it bounds from below: the one with a +//! negative coefficient in `expression ≤ 0` (or any nonzero one in an +//! equality) that was created last, since the model creates each variable +//! after the variables its defining rows read. Rows of the families in +//! [`CHECK_ONLY_FAMILIES`] (upper bounds of reach, reader constraints, and +//! valid inequalities a plan's values meet anyway) define nothing. The +//! values are computed in creation order, repeated until no value changes, +//! then every row and bound is checked. A plan that fails a check is +//! infeasible as far as this completion goes (HiGHS's LP completion could +//! find another one; the warm start then just skips that plan). +//! +//! Each value is the smallest its defining rows allow, so at a plan's +//! binaries the values are the plan's own reach, latencies, held times and +//! rates, and the objective is what the model charges the plan: with the +//! exact cost terms of [`super::cost_terms`], what `asap_cost::plan_cost` +//! says (the warm start's test checks it). +use std::collections::HashMap; + +use good_lp::{IntoAffineExpression, Variable}; + +use super::model::SelectionMilp; + +/// Families whose rows only check values (upper bounds of reach, reader +/// constraints, rows tests add) and define no variable. +const CHECK_ONLY_FAMILIES: [&str; 4] = [ + "6 reach from above", + "9 reader constraints", + "V (slowest rate)", + "test", +]; + +/// The most passes over the variables before giving up on a fixpoint. +const MOST_PASSES: usize = 64; + +/// One row in `Σ coefficient · column + constant ≤ 0` (or `= 0`). +struct Row { + /// Range into `columns` and `coefficients`. + start: usize, + end: usize, + constant: f64, + equality: bool, +} + +/// The model's rows, compiled for repeated completions. +pub(crate) struct Completion { + column_of: HashMap, + /// The variables, by column. + variables: Vec, + binary: Vec, + max: Vec, + rows: Vec, + columns: Vec, + coefficients: Vec, + /// Per column, the rows defining it. + defining_rows: Vec>, + /// Per column with no defining row, the rows bounding it from above. + upper_rows: Vec>, + /// The columns with defining rows, in creation order. + defined: Vec, + /// The columns with neither (free continuous variables), in creation + /// order. + free: Vec, + objective: Vec<(usize, f64)>, + /// The family of each row. + families: Vec<&'static str>, +} + +/// A completed plan. +pub(crate) struct Completed { + /// Every variable's value, in creation order. + pub(crate) columns: Vec, + /// The objective (cost per second without the request fees). + pub(crate) cost_per_second: f64, +} + +impl Completion { + pub(crate) fn new(milp: &SelectionMilp<'_>) -> Self { + let builder = &milp.builder; + let mut column_of = HashMap::new(); + let mut variables = Vec::new(); + let mut binary = Vec::new(); + let mut max = Vec::new(); + for (column, (variable, definition)) in + builder.variables.iter_variables_with_def().enumerate() + { + column_of.insert(variable, column); + variables.push(variable); + binary.push(definition.is_integer()); + max.push(definition.get_max()); + } + let count = binary.len(); + let mut rows = Vec::with_capacity(builder.rows.len()); + let mut columns = Vec::new(); + let mut coefficients = Vec::new(); + let mut defining_rows: Vec> = vec![Vec::new(); count]; + let mut positive_rows: Vec> = vec![Vec::new(); count]; + for (index, (row, family)) in builder.rows.iter().zip(&builder.row_families).enumerate() { + let expression = row.expression(); + let start = columns.len(); + let mut defines: Option = None; + for (variable, coefficient) in expression.linear_coefficients() { + if coefficient == 0.0 { + continue; + } + let column = column_of[&variable]; + columns.push(column as u32); + coefficients.push(coefficient); + if binary[column] { + continue; + } + if coefficient > 0.0 { + positive_rows[column].push(index as u32); + } + if (coefficient < 0.0 || row.is_equality()) + && defines.is_none_or(|known| column > known) + { + defines = Some(column); + } + } + if !CHECK_ONLY_FAMILIES.contains(family) { + if let Some(column) = defines { + defining_rows[column].push(index as u32); + } + } + rows.push(Row { + start, + end: columns.len(), + constant: expression.constant(), + equality: row.is_equality(), + }); + } + let mut defined = Vec::new(); + let mut free = Vec::new(); + let mut upper_rows: Vec> = vec![Vec::new(); count]; + for column in 0..count { + if binary[column] { + continue; + } + if defining_rows[column].is_empty() { + upper_rows[column] = std::mem::take(&mut positive_rows[column]); + free.push(column); + } else { + defined.push(column); + } + } + let objective = milp + .objective_terms + .iter() + .map(|&(variable, coefficient)| (column_of[&variable], coefficient)) + .collect(); + Self { + column_of, + variables, + binary, + max, + rows, + columns, + coefficients, + defining_rows, + upper_rows, + defined, + free, + objective, + families: builder.row_families.clone(), + } + } + + /// The completion of a solver's columns (in creation order): its + /// binaries rounded, every continuous variable recomputed. + pub(crate) fn complete_columns(&self, columns: &[f64]) -> Result { + let binaries: Vec<(Variable, f64)> = self + .variables + .iter() + .zip(columns) + .zip(&self.binary) + .filter(|(_, &binary)| binary) + .map(|((&variable, &value), _)| (variable, if value > 0.5 { 1.0 } else { 0.0 })) + .collect(); + self.complete(&binaries) + } + + /// Every variable's value in `completed`. + pub(crate) fn values(&self, completed: &Completed) -> Vec<(Variable, f64)> { + self.variables + .iter() + .copied() + .zip(completed.columns.iter().copied()) + .collect() + } + + /// `Σ coefficient · value + constant` of row `row`, without column + /// `skip`, and the coefficient of `skip`. + fn row_value(&self, row: &Row, values: &[f64], skip: usize) -> (f64, f64) { + let mut sum = row.constant; + let mut own = 0.0; + for at in row.start..row.end { + let column = self.columns[at] as usize; + if column == skip { + own += self.coefficients[at]; + } else { + sum += self.coefficients[at] * values[column]; + } + } + (sum, own) + } + + /// The cheapest completion of `binaries` (every binary not listed is 0), + /// or why it has none here: the row or bound it breaks. + pub(crate) fn complete(&self, binaries: &[(Variable, f64)]) -> Result { + let mut values = vec![0.0; self.binary.len()]; + for &(variable, value) in binaries { + let column = self.column_of[&variable]; + if self.binary[column] { + values[column] = value; + } + } + // Defined columns: the largest lower bound, to a fixpoint. + let mut converged = false; + for _ in 0..MOST_PASSES { + let mut changed = false; + for &column in &self.defined { + let mut value: f64 = 0.0; + for &row in &self.defining_rows[column] { + let row = &self.rows[row as usize]; + let (rest, own) = self.row_value(row, &values, column); + // own · v + rest ≤ 0 (or = 0) with own < 0 for an inequality. + let bound = -rest / own; + if row.equality { + value = bound; + break; + } + value = value.max(bound); + } + if (value - values[column]).abs() > 1e-12 * value.abs().max(1.0) { + changed = true; + } + values[column] = value; + } + if !changed { + converged = true; + break; + } + } + if !converged { + return Err("no fixpoint of the lower bounds".into()); + } + // Free columns: the smallest upper bound. + for &column in &self.free { + let mut value = self.max[column]; + for &row in &self.upper_rows[column] { + let row = &self.rows[row as usize]; + let (rest, own) = self.row_value(row, &values, column); + value = value.min(-rest / own); + } + values[column] = if value.is_finite() { + value.max(0.0) + } else { + 0.0 + }; + } + // Every row and bound. + for (index, row) in self.rows.iter().enumerate() { + let mut sum = row.constant; + let mut magnitude = row.constant.abs(); + for at in row.start..row.end { + let term = self.coefficients[at] * values[self.columns[at] as usize]; + sum += term; + magnitude += term.abs(); + } + let tolerance = 1e-7 * magnitude.max(1.0); + if sum > tolerance || (row.equality && sum < -tolerance) { + return Err(format!( + "row {index} ({}) off by {sum:e}", + self.families[index] + )); + } + } + for (column, &value) in values.iter().enumerate() { + if value > self.max[column] + 1e-7 * self.max[column].abs().max(1.0) { + return Err(format!("column {column} at {value:e} above its bound")); + } + } + let cost_per_second = self + .objective + .iter() + .map(|&(column, coefficient)| coefficient * values[column]) + .sum(); + Ok(Completed { + columns: values, + cost_per_second, + }) + } +} diff --git a/crates/plan-selection/src/milp/cost_terms.rs b/crates/plan-selection/src/milp/cost_terms.rs new file mode 100644 index 000000000..4d8bf050e --- /dev/null +++ b/crates/plan-selection/src/milp/cost_terms.rs @@ -0,0 +1,1175 @@ +//! #1095 §3: the cost model 1 objective of the group selection MILP. Every +//! number is `asap-cost`'s: a candidate's usage per execution and standing +//! cost ([`CandidateUsage`], from [`asap_cost::node_usage`] in +//! [`crate::groups`]), the schedule groups +//! ([`asap_cost::execution_demand::schedule_groups`]) and rates +//! ([`asap_cost::execution_demand::evaluation_rate`]) and the unit costs of +//! [`asap_cost::ResourceUnitCost`]. This module only turns them into +//! coefficients and the auxiliary rows that linearize them. +//! +//! A **run** is a candidate computed in an execution context (`p[ctx, c]`), +//! or a shareable candidate's own run (`x[m]`, keyed by its own context). +//! A run's usage is one or more [`RunTerm`]s, each an indicator and the +//! numbers that count when it is 1: +//! - one term on the run's own indicator when its usage is plan +//! independent; +//! - per producer choice, when its usage depends on which candidate of an +//! input's slot the context picks (a summary state's configuration, a +//! scan's window): `AND(run, p[ctx, c'], producer gives that output)`; +//! - for a query-time scan read at several windows in one context: one +//! term per set `S` of window kinds read, `δ_S`, the exact indicator that +//! exactly the kinds in `S` are read (from `σ_W`, the exact OR of the +//! reads at kind `W`), costed at the union of `S`'s windows +//! (`PhysicalASAPDAG::scan_window`). This needs no monotonicity: every +//! union is costed by `asap-cost` itself. +//! +//! **Executions per second** of a run: `rate(q)` in query context `q`; 1 in +//! `I` and for an ingestion-time shareable candidate; for a query-time +//! shareable candidate `m` and the runs in its context, `Σ rate · z[m, G, +//! ℓ]` over its schedule groups and their levels, `z ≥ a[q, m]` for each +//! reader at that level or faster (a group's rate is its fastest reader's). +//! A context's runs other than `m`'s plain own term are summed once, `E[m] +//! ≥ Σ cost · indicator`, and charged per component, `V ≥ E[m] − Ē[m] (x[m] +//! − z)`, with `Σ rate · V ≥ (slowest rate) · E[m]`. +//! +//! **Held memory**: working memory of a run in query context `q` for `q`'s +//! latency at `q`'s rate (`H_query ≥ L[q] − L̄[q] (1 − indicator)`); `m`'s +//! `MatAtQuery(Memory)` result and the working memory of the runs in its +//! context until `m`'s slowest reader finishes (`H[m] ≥ L[q] − L̄[q] (1 − +//! a[q, m])`, then `W ≥ H[m] − H̄[m] (slack − Σ indicators)` per rate +//! component). +//! +//! **Raw retention**: per source, levels at each raw-data size a query-time +//! scan run may keep, `v[src, ℓ] ≥` each scan term keeping at least that +//! much; charged `Δbytes · unit disk cost`. +//! +//! **Pane retention**: a kept pane chain's usage at the fewest panes any +//! reader needs on `x[m]`, and each larger retention's increase on `π[m, +//! ℓ] ≥` the reach (as `plan_cost` counts it: through query-time runs) of +//! each query needing at least that many panes. +//! +//! Terms `plan_cost` defines differently from the above are counted in +//! [`SelectionMilp::inexact`] by reason. +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use asap_cost::execution_demand::{evaluation_rate, schedule_groups}; +use asap_cost::CostModel; +use asap_types::ir::physical_export::ScanWindow; +use asap_types::ir::properties::ExecutionTiming; +use asap_types::ir::{NonASAPOp, Operator}; +use asap_types::physical::{Materialization, Medium}; +use asap_types::stages::PhysicalCandidateId; +use asap_types::workload::bounds::source_name; +use asap_types::workload::{QueryRecurrence, RepeatedDemand}; +use good_lp::{constraint, Expression, IntoAffineExpression, Variable}; + +use crate::groups::{CandidateUsage, ContextKind, RunKind, UsageAndOutput, UsageContext}; + +use super::model::{Reader, Scope, SelectionMilp, SelectionMilpError}; + +use crate::groups::MAX_WINDOW_KINDS; + +/// The numbers of one run `asap-cost` computed, as the objective and the +/// latency rows read them. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(crate) struct NodeNumbers { + /// Cost of one execution. + pub(crate) execution_cost: f64, + /// Cost per second of what it keeps whatever runs it. + pub(crate) standing_cost: f64, + /// What it adds to a reaching query's latency, ms. + pub(crate) latency_ms: f64, + /// Query-time memory held while an evaluation reading it runs. + pub(crate) held_bytes: f64, + /// Raw data a query-time scan keeps on disk. + pub(crate) raw_kept_bytes: f64, +} + +impl NodeNumbers { + pub(crate) fn of(usage: &CandidateUsage) -> Self { + Self { + execution_cost: usage.cost_per_execution.unwrap_or(0.0), + standing_cost: usage.standing_cost_per_second.unwrap_or(0.0), + latency_ms: usage.own_latency_ms, + held_bytes: usage.held_bytes as f64, + raw_kept_bytes: usage.raw_data_kept_bytes as f64, + } + } + + /// `self − other`, each number at least 0, and whether one was below. + fn minus(self, other: Self) -> (Self, bool) { + let fields = [ + self.execution_cost - other.execution_cost, + self.standing_cost - other.standing_cost, + self.latency_ms - other.latency_ms, + self.held_bytes - other.held_bytes, + self.raw_kept_bytes - other.raw_kept_bytes, + ]; + let negative = fields + .iter() + .any(|&field| field < -1e-12 * field.abs().max(1.0)); + let [execution_cost, standing_cost, latency_ms, held_bytes, raw_kept_bytes] = + fields.map(|field| field.max(0.0)); + ( + Self { + execution_cost, + standing_cost, + latency_ms, + held_bytes, + raw_kept_bytes, + }, + negative, + ) + } +} + +/// One term of a run: its numbers count, and it gives its readers output +/// `output`, when `indicator` is 1. At integral binaries exactly one term +/// of a used run is 1, except a kept pane chain's retention increases, +/// which add to its base term. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RunTerm { + pub(crate) indicator: Expression, + pub(crate) numbers: NodeNumbers, + /// Whether `indicator` is the run's own variable (`p[ctx, c]` or + /// `x[m]`). + pub(crate) plain: bool, + /// Its output (an index below `CandidateData::output_count`). + pub(crate) output: usize, +} + +/// Run `(context, candidate)`'s terms (module documentation). Empty when +/// the run does not exist. +pub(crate) fn run_terms( + milp: &mut SelectionMilp<'_>, + context: usize, + candidate: PhysicalCandidateId, +) -> Vec { + let Some(run) = milp.run_indicator(context, candidate) else { + return Vec::new(); + }; + let data = &milp.inputs.candidate_data[candidate.0]; + let Ok(base) = &data.usage else { + return Vec::new(); + }; + let base = *base; + for usage_context in data.usage_depends_on.clone() { + match usage_context { + UsageContext::ProducerOnACycle => milp.note_inexact("usage: a producer on a cycle"), + UsageContext::TooManyContexts => milp.note_inexact("usage: too many contexts"), + UsageContext::ProducerOutput + | UsageContext::ScanWindowOfReader + | UsageContext::PaneRetentionOfReachingQueries + | UsageContext::UncostableContext => {} + } + } + if milp.inputs.candidate_data[candidate.0].producer_usage_ambiguous { + milp.note_inexact("usage: a producer's output depends on its own producers"); + } + if !milp.inputs.candidate_data[candidate.0] + .usage_per_window + .is_empty() + { + if let Some(terms) = window_terms(milp, context, candidate, run) { + return terms; + } + } + if !milp.inputs.candidate_data[candidate.0] + .inputs_changing_usage + .is_empty() + { + return producer_terms(milp, context, candidate, run); + } + let mut terms = vec![RunTerm { + indicator: run.into(), + numbers: NodeNumbers::of(&base), + plain: true, + output: 0, + }]; + if !milp.inputs.candidate_data[candidate.0] + .usage_per_retained_panes + .is_empty() + { + pane_retention_terms(milp, context, candidate, &mut terms); + } + terms +} + +/// The indicator that, in `context`, the producer `producer` of a reader is +/// read and gives one of `outputs` (`None`: any output): `p[ctx, c']`, or +/// `AND(p[ctx, c'], t)` with `t ≥` the producer run's terms giving them. +/// `None` when the producer never gives them. +fn producer_gives( + milp: &mut SelectionMilp<'_>, + context: usize, + producer: PhysicalCandidateId, + outputs: Option<&BTreeSet>, +) -> Option { + let read = *milp.choice[context].get(&producer)?; + let Some(outputs) = outputs else { + return Some(read.into()); + }; + // The producer's run: computed here, its own run, or its run in `I`. + let producer_run = match milp.kind(producer) { + _ if milp.computes(context, producer) => context, + RunKind::Shareable => milp.context_of(producer)?, + RunKind::Ingestion => milp.ingestion_context(), + RunKind::PerContext => return None, + }; + if milp.terms_in_progress.contains(&(producer_run, producer)) { + milp.note_inexact("usage: a producer's output on a cycle of links (every output kept)"); + return Some(read.into()); + } + let producer_terms = milp.run_terms(producer_run, producer); + if producer_terms + .iter() + .all(|term| outputs.contains(&term.output)) + { + return Some(read.into()); + } + let gives: Vec<&RunTerm> = producer_terms + .iter() + .filter(|term| outputs.contains(&term.output)) + .collect(); + if gives.is_empty() { + return None; + } + let key = ( + producer_run, + producer, + outputs.iter().copied().collect::>(), + ); + let gives = match milp.producer_gives.get(&key) { + Some(&gives) => gives, + None => { + let sum: Expression = gives.iter().map(|term| term.indicator.clone()).sum(); + let variable = milp + .builder + .continuous("t_outputs (a producer gives outputs)", Some(1.0)); + milp.builder.row( + "t_outputs (a producer gives outputs)", + constraint!(variable >= sum), + ); + milp.producer_gives.insert(key, variable); + variable + } + }; + if milp.computes(context, producer) { + return Some(gives.into()); + } + Some( + milp.builder + .and_at_least("t_producers (AND)", &[read.into(), gives.into()]) + .into(), + ) +} + +/// A producer choice with the outputs it stands for (`None`: any output). +type OutputsOfProducer = (PhysicalCandidateId, Option>); + +/// Terms of a run whose usage or output depends on its producers (and +/// their outputs): one per choice, on `AND(run, producer gives)` over the +/// changing inputs. +fn producer_terms( + milp: &mut SelectionMilp<'_>, + context: usize, + candidate: PhysicalCandidateId, + run: Variable, +) -> Vec { + milp.terms_in_progress.insert((context, candidate)); + let data = &milp.inputs.candidate_data[candidate.0]; + let changing = data.inputs_changing_usage.clone(); + let base = *data.usage.as_ref().expect("costed"); + let same = |known: &Option, gives: &Option| match (known, gives) + { + (Some(known), Some(gives)) => known == gives, + (None, None) => true, + _ => false, + }; + let mut table: Vec<(Vec, Option)> = Vec::new(); + for (choices, gives) in &data.usage_per_producers { + let choices: Vec = choices + .iter() + .map(|choice| { + ( + choice.class, + choice.output.map(|output| BTreeSet::from([output])), + ) + }) + .collect(); + if changing.len() == 1 { + if let Some((known, _)) = table + .iter_mut() + .find(|(known, known_gives)| known[0].0 == choices[0].0 && same(known_gives, gives)) + { + if let (Some(outputs), Some(more)) = (&mut known[0].1, &choices[0].1) { + outputs.extend(more); + } + continue; + } + } + table.push((choices, *gives)); + } + let producers_here: Vec> = changing + .iter() + .map(|&input| { + milp.inputs.producer_candidates_per_input[candidate.0][input] + .iter() + .copied() + .filter(|producer| milp.choice[context].contains_key(producer)) + .filter(|producer| milp.slot(*producer) != milp.slot(candidate)) + .collect() + }) + .collect(); + let mut covered: Vec> = vec![BTreeSet::new(); changing.len()]; + let mut terms = Vec::new(); + for (choices, gives) in &table { + let mut per_input: Vec = Vec::with_capacity(choices.len()); + for (position, (producer, outputs)) in choices.iter().enumerate() { + if !producers_here[position].contains(producer) { + break; + } + covered[position].insert(*producer); + match producer_gives(milp, context, *producer, outputs.as_ref()) { + Some(indicator) => per_input.push(indicator), + None => break, + } + } + if per_input.len() < choices.len() { + continue; + } + let mut all: Vec = vec![run.into()]; + all.extend(per_input); + // A multi-slot input reads the choice's slot only through its link. + for (position, (producer, _)) in choices.iter().enumerate() { + if let Some(links) = milp + .slot_links + .get(&(context, candidate, changing[position])) + { + let slot = milp.slot(*producer); + if let Some(&(_, link)) = links.iter().find(|(at, _)| *at == slot) { + all.push(link.into()); + } + } + } + let count = all.len() as f64; + match gives { + None => { + let sum: Expression = all.into_iter().sum(); + milp.builder + .row("t_producers (AND)", constraint!(sum <= count - 1.0)); + } + Some((usage, output)) => { + let indicator = milp.builder.and_at_least("t_producers (AND)", &all); + terms.push(RunTerm { + indicator: indicator.into(), + numbers: NodeNumbers::of(usage), + plain: false, + output: *output, + }); + } + } + } + // A producer missing from the table: its reads carry the first costed + // usage. + let mut missing = false; + for (position, producers) in producers_here.iter().enumerate() { + for &producer in producers { + if covered[position].contains(&producer) { + continue; + } + missing = true; + let read = milp.choice[context][&producer]; + let indicator = milp + .builder + .and_at_least("t_producers (AND)", &[run.into(), read.into()]); + terms.push(RunTerm { + indicator: indicator.into(), + numbers: NodeNumbers::of(&base), + plain: false, + output: 0, + }); + } + } + if missing { + milp.note_inexact("usage: a producer missing from its usage table"); + } + if terms.is_empty() { + milp.builder + .row("t_producers (AND)", constraint!(run <= 0.0)); + } + milp.terms_in_progress.remove(&(context, candidate)); + terms +} + +/// The union of `windows`, as [`PhysicalASAPDAG::scan_window`] computes it +/// for one scan read at each: the nearest end and the farthest start. +pub(crate) fn window_union(windows: impl IntoIterator) -> ScanWindow { + let mut near = i64::MAX; + let mut far = i64::MIN; + for window in windows { + match window { + ScanWindow::Unbounded => return ScanWindow::Unbounded, + ScanWindow::Within { + end_offset_ms, + width_ms, + } => { + near = near.min(end_offset_ms); + far = far.max(end_offset_ms.saturating_add(width_ms)); + } + } + } + if near > far { + return ScanWindow::Unbounded; + } + ScanWindow::Within { + end_offset_ms: near, + width_ms: far.saturating_sub(near), + } +} + +/// The window a scan run read at `kinds` scans, as `asap_cost::plan_cost` +/// prices it: their union ([`window_union`]) when every kind is bounded; +/// with an unbounded read (a reader that is not a time range, a pane chain +/// or a time shift, or a read as an output), the trailing window of the +/// widest bounded kind (`plan_cost`'s `widest_window_read_ms`), or +/// `Unbounded` when there is none. +pub(crate) fn scan_window_read_at( + kinds: impl IntoIterator + Clone, +) -> ScanWindow { + let unbounded = kinds + .clone() + .into_iter() + .any(|kind| kind == ScanWindow::Unbounded); + if !unbounded { + return window_union(kinds); + } + let widest = kinds + .into_iter() + .filter_map(|kind| match kind { + ScanWindow::Within { width_ms, .. } => Some(width_ms), + ScanWindow::Unbounded => None, + }) + .max(); + match widest { + Some(width_ms) => ScanWindow::Within { + end_offset_ms: 0, + width_ms, + }, + None => ScanWindow::Unbounded, + } +} + +/// The windows reader `reader` reads scan `scan` at (one per path through +/// time shifts); `Unbounded` when unknown. +fn reader_windows( + milp: &SelectionMilp<'_>, + scan: PhysicalCandidateId, + reader: PhysicalCandidateId, +) -> Vec { + milp.inputs.candidate_data[scan.0] + .windows_per_reader + .iter() + .find(|(known, _)| *known == reader) + .map(|(_, read)| read.clone()) + .unwrap_or_else(|| vec![ScanWindow::Unbounded]) +} + +/// Terms of query-time scan run `(context, scan)` whose usage depends on +/// the window its readers read (module documentation). `None` when its +/// readers read one window kind and it has no other dependence handled +/// here: the caller then uses its other terms. +fn window_terms( + milp: &mut SelectionMilp<'_>, + context: usize, + scan: PhysicalCandidateId, + run: Variable, +) -> Option> { + if let Some(cached) = milp.window_cache.get(&(context, scan)) { + return cached.clone(); + } + // Each read: (reading context, window kind, reader's indicator). + let mut reads: Vec<(usize, ScanWindow, Expression)> = Vec::new(); + let reading_contexts: Vec = if milp.kind(scan) == RunKind::Shareable { + (0..milp.choice.len()) + .filter(|&other| milp.choice[other].contains_key(&scan)) + .collect() + } else { + vec![context] + }; + for &reading in &reading_contexts { + for reader in milp.readers[reading] + .get(&scan) + .cloned() + .unwrap_or_default() + { + let (window, indicator) = match reader { + Reader::Candidate(reader, _) => { + let windows = reader_windows(milp, scan, reader); + if windows.len() > 1 { + milp.note_inexact("a scan read at several windows by one reader (union)"); + } + ( + window_union(windows), + Expression::from(milp.choice[reading][&reader]), + ) + } + Reader::Output(_) => (ScanWindow::Unbounded, Expression::from(1.0)), + Reader::OwnInput(_) => { + let ContextKind::Shared(own) = milp.inputs.contexts[reading].kind else { + continue; + }; + (ScanWindow::Unbounded, Expression::from(milp.selected[&own])) + } + }; + reads.push((reading, window, indicator)); + } + } + let mut kinds: Vec = Vec::new(); + for (_, window, _) in &reads { + if !kinds.contains(window) { + kinds.push(*window); + } + } + let usage_at = |milp: &SelectionMilp<'_>, window: ScanWindow| { + milp.inputs.candidate_data[scan.0] + .usage_per_window + .iter() + .find(|(known, _, _)| *known == window) + .map(|&(_, usage, output)| (NodeNumbers::of(&usage), output)) + }; + let terms = match kinds.len() { + 0 => None, + 1 => match usage_at(milp, scan_window_read_at([kinds[0]])) { + Some((numbers, output)) => Some(vec![RunTerm { + indicator: run.into(), + numbers, + plain: true, + output, + }]), + None => { + // `asap-cost` cannot cost the scan so: no plan reads it so. + milp.builder + .row("δ (scan window sets)", constraint!(run <= 0.0)); + Some(Vec::new()) + } + }, + count if count <= MAX_WINDOW_KINDS => { + // `σ_W`: the exact OR, over the reading contexts, of "the + // context reads the scan and a reader at kind `W` is used" + // (`w`, exact from both sides at integral binaries). + let mut sigma = Vec::with_capacity(count); + for kind in &kinds { + let mut per_context: Vec = Vec::new(); + for &reading in &reading_contexts { + let at_kind: Vec<&Expression> = reads + .iter() + .filter(|(at, window, _)| *at == reading && window == kind) + .map(|(_, _, indicator)| indicator) + .collect(); + if at_kind.is_empty() { + continue; + } + let read_here = milp.choice[reading][&scan]; + let used = milp.builder.continuous("σ (scan window kinds)", Some(1.0)); + let mut upper = Expression::from(0.0); + for indicator in at_kind { + let indicator = indicator.clone(); + upper += indicator.clone(); + milp.builder.row( + "σ (scan window kinds)", + constraint!(used >= indicator + read_here - 1.0), + ); + } + milp.builder + .row("σ (scan window kinds)", constraint!(used <= read_here)); + milp.builder + .row("σ (scan window kinds)", constraint!(used <= upper)); + per_context.push(used); + } + let or = match per_context.as_slice() { + [one] => *one, + many => { + let or = milp.builder.continuous("σ (scan window kinds)", Some(1.0)); + let mut upper = Expression::from(0.0); + for &used in many { + milp.builder + .row("σ (scan window kinds)", constraint!(or >= used)); + upper += used; + } + milp.builder + .row("σ (scan window kinds)", constraint!(or <= upper)); + or + } + }; + sigma.push(or); + } + let mut terms = Vec::new(); + for set in 1usize..(1 << count) { + let window = scan_window_read_at( + (0..count) + .filter(|bit| set & (1 << bit) != 0) + .map(|bit| kinds[bit]), + ); + let members = set.count_ones() as f64; + let mut sum = Expression::from(0.0); + for (bit, &or) in sigma.iter().enumerate() { + if set & (1 << bit) != 0 { + sum += or; + } else { + sum -= or; + } + } + let Some((numbers, output)) = usage_at(milp, window) else { + // `asap-cost` cannot cost the scan at this union: no + // plan reads exactly these kinds. + milp.builder + .row("δ (scan window sets)", constraint!(sum <= members - 1.0)); + continue; + }; + let exactly = milp.builder.continuous("δ (scan window sets)", Some(1.0)); + milp.builder.row( + "δ (scan window sets)", + constraint!(exactly >= sum - (members - 1.0)), + ); + terms.push(RunTerm { + indicator: exactly.into(), + numbers, + plain: false, + output, + }); + } + Some(terms) + } + _ => { + milp.note_inexact("a scan read at more window kinds than encoded (widest charged)"); + let widest = scan_window_read_at(kinds.iter().copied()); + usage_at(milp, widest).map(|(numbers, output)| { + vec![RunTerm { + indicator: run.into(), + numbers, + plain: true, + output, + }] + }) + } + }; + milp.window_cache.insert((context, scan), terms.clone()); + terms +} + +/// For a shareable kept pane chain with usage depending on the panes it +/// retains: its base term at the fewest panes, plus one term per larger +/// retention, `π[m, ℓ] ≥` the reach of each query needing at least `ℓ`. +fn pane_retention_terms( + milp: &mut SelectionMilp<'_>, + context: usize, + chain: PhysicalCandidateId, + terms: &mut Vec, +) { + let data = &milp.inputs.candidate_data[chain.0]; + let mut levels = data.usage_per_retained_panes.clone(); + levels.sort_by_key(|(panes, _)| *panes); + let per_query = data.retained_panes_per_query.clone(); + let Some(&(_, base)) = levels.first() else { + return; + }; + terms[0].numbers = NodeNumbers::of(&base); + let at_query_time = milp.runs_at_query_time(context); + // Per query, the indicator that it reaches the chain as `plan_cost` + // counts reach (through query-time runs). + let mut reached: Vec<(usize, Vec)> = Vec::new(); + for &(query, _) in &per_query { + let mut indicators = Vec::new(); + if let Some(&reach) = milp.reach.get(&(query, chain)) { + if at_query_time + || milp.physical.candidates[chain.0].timing == ExecutionTiming::QueryTime + { + indicators.push(Expression::from(reach)); + reached.push((query, indicators)); + continue; + } + } + if let Some(&direct) = milp.choice[query].get(&chain) { + indicators.push(direct.into()); + } + for &reader in milp + .shared_readers + .get(&chain) + .cloned() + .unwrap_or_default() + .iter() + { + let reader_context = milp.inputs.context_of_shareable[&reader]; + if !milp.runs_at_query_time(reader_context) { + continue; + } + if let Some(&above) = milp.reach.get(&(query, reader)) { + let read = milp.choice[reader_context][&chain]; + let and = milp + .builder + .and_at_least("π (pane retention levels)", &[above.into(), read.into()]); + indicators.push(and.into()); + } + } + reached.push((query, indicators)); + } + let mut below = NodeNumbers::of(&base); + for &(panes, usage) in &levels[1..] { + let numbers = NodeNumbers::of(&usage); + let (increase, negative) = numbers.minus(below); + below = numbers; + if negative { + milp.note_inexact("a longer pane retention with a smaller number"); + } + if increase.execution_cost > 0.0 && at_query_time { + milp.note_inexact("pane retention changing a query-time execution cost"); + } + if increase.latency_ms > 0.0 || increase.held_bytes > 0.0 { + milp.note_inexact("pane retention changing latency or held memory (not encoded)"); + } + let level = milp + .builder + .continuous("π (pane retention levels)", Some(1.0)); + for (query, indicators) in &reached { + let needs = per_query + .iter() + .any(|&(q, retained)| q == *query && retained >= panes); + if !needs { + continue; + } + for indicator in indicators { + let indicator = indicator.clone(); + milp.builder + .row("π (pane retention levels)", constraint!(level >= indicator)); + } + } + terms.push(RunTerm { + indicator: level.into(), + numbers: NodeNumbers { + latency_ms: 0.0, + held_bytes: 0.0, + ..increase + }, + plain: false, + output: terms[0].output, + }); + } +} + +/// Each ingestion-time shareable candidate's compaction term for its +/// readers' latency, ms, where `plan_cost` charges one: `(k − 1) · states · +/// merge time`, `k` the cores its ingestion work takes. With at most one +/// second of ingestion work per second below it, `k = 1` and the term is 0; +/// a larger subtree is not encoded (counted as inexact). +pub(crate) fn compaction_ms(milp: &mut SelectionMilp<'_>) -> HashMap { + let inputs = milp.inputs; + let mut compaction = HashMap::new(); + for &shareable in inputs.context_of_shareable.keys() { + if milp.physical.candidates[shareable.0].timing != ExecutionTiming::IngestionTime { + continue; + } + let mut seen = BTreeSet::new(); + let mut stack = vec![shareable]; + let mut most_work_ms = 0.0; + while let Some(class) = stack.pop() { + if !seen.insert(class) { + continue; + } + if let Ok(usage) = &inputs.candidate_data[class.0].usage { + most_work_ms += usage.work_ms_per_execution; + } + stack.extend( + inputs.producer_candidates_per_input[class.0] + .iter() + .flatten() + .copied(), + ); + } + if most_work_ms > 1_000.0 { + milp.note_inexact("compaction with more than one ingest worker (not encoded)"); + } + compaction.insert(shareable, 0.0); + } + compaction +} + +/// A schedule group's readers with their own rates, and its rate levels. +type GroupLevels = (Vec<(usize, f64)>, Vec); + +/// How the runs of a context execute per second. +#[derive(Debug, Clone)] +enum ContextRate { + /// A fixed rate (a query's, 1 at ingestion time, or a query-time + /// shareable candidate whose possible readers run it at one rate). + Fixed(f64), + /// `Σ rate · z` over a query-time shareable candidate's schedule-group + /// levels. + Components(Vec<(Variable, f64)>), +} + +/// Each context's rate (module documentation), and the queries' own +/// rates. +fn context_rates( + milp: &mut SelectionMilp<'_>, +) -> Result<(Vec, Vec), SelectionMilpError> { + let horizon = milp.cost_inputs.non_repeating_query_horizon_seconds; + let cost = |error: asap_cost::analytical::AnalyticalCostError| { + SelectionMilpError::Cost(error.to_string()) + }; + let query_rate: Vec = milp + .entries + .iter() + .map(|entry| evaluation_rate([(&entry.recurrence, entry.range_evaluation)], horizon)) + .collect::>() + .map_err(cost)?; + let mut rates = Vec::with_capacity(milp.inputs.contexts.len()); + milp.builder.scope = Scope::SharedContexts; + for context in 0..milp.inputs.contexts.len() { + let rate = match milp.inputs.contexts[context].kind { + ContextKind::Query(query) => ContextRate::Fixed(query_rate[query]), + ContextKind::Ingestion => ContextRate::Fixed(1.0), + ContextKind::Shared(m) => { + let per_request = match milp.physical.candidates[m.0].materialization { + Materialization::MatAtIngestion(_) | Materialization::NotMaterialized => { + rates.push(ContextRate::Fixed(1.0)); + continue; + } + Materialization::MatAtQuery(_) => false, + Materialization::MatAtQueryKeptAcrossEvaluations(_) => true, + }; + let readers = milp.queries_reaching(m); + let demand = |query: usize| { + let entry = &milp.entries[query]; + ( + &entry.recurrence, + if per_request { + None + } else { + entry.range_evaluation + }, + ) + }; + if unknown_phase_joins_several( + readers.iter().map(|&query| &milp.entries[query].recurrence), + ) { + milp.note_inexact("schedule: an unknown phase among several known ones"); + } + let groups = schedule_groups(readers.iter().map(|&query| demand(query)), horizon) + .map_err(cost)?; + let mut levels_per_group: Vec = Vec::new(); + for group in groups { + let members: Vec = group + .readers + .iter() + .map(|&position| readers[position]) + .collect(); + let alone: Vec = members + .iter() + .map(|&query| evaluation_rate([demand(query)], horizon)) + .collect::>() + .map_err(cost)?; + let fastest = alone.iter().copied().fold(0.0, f64::max); + let full = group.evaluations_per_second; + let mut levels: Vec = alone.clone(); + if (full - fastest).abs() > 1e-9 * full.abs().max(1e-12) { + milp.note_inexact("schedule: a group's rate is not its fastest reader's"); + levels = vec![full]; + } + levels.sort_by(f64::total_cmp); + levels.dedup(); + levels_per_group.push((members.into_iter().zip(alone).collect(), levels)); + } + if let [(_, levels)] = levels_per_group.as_slice() { + if let [level] = levels.as_slice() { + rates.push(ContextRate::Fixed(*level)); + continue; + } + } + let mut components = Vec::new(); + for (members, levels) in levels_per_group { + let mut below = 0.0; + for &level in &levels { + let z = milp.builder.continuous("z (schedule groups)", Some(1.0)); + for &(query, own) in &members { + if own >= level || levels.len() == 1 { + let reach = milp.reach[&(query, m)]; + milp.builder + .row("z (schedule groups)", constraint!(z >= reach)); + } + } + components.push((z, level - below)); + below = level; + } + } + ContextRate::Components(components) + } + }; + rates.push(rate); + } + milp.builder.scope = Scope::Global; + Ok((rates, query_rate)) +} + +/// Whether some interval has readers of unknown phase and readers of two +/// or more known phases. +fn unknown_phase_joins_several<'r>(recurrences: impl Iterator) -> bool { + let mut phases: BTreeMap)> = BTreeMap::new(); + for recurrence in recurrences { + match recurrence { + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval)) => { + phases.entry(interval.0).or_default().0 = true; + } + QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { + interval, + evaluation_phase, + }) => { + phases + .entry(interval.0) + .or_default() + .1 + .insert(evaluation_phase.0 % u64::from(interval.0.max(1))); + } + _ => {} + } + } + phases + .values() + .any(|(unknown, known)| *unknown && known.len() > 1) +} + +/// The objective (cost model 1, module documentation) and its rows. +pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), SelectionMilpError> { + let CostModel::UsageOverTime(unit_cost) = milp.cost_inputs.cost_model.clone() else { + return Err(SelectionMilpError::UnsupportedCostModel("not cost model 1")); + }; + let horizon = milp.cost_inputs.non_repeating_query_horizon_seconds; + let cost = |error: asap_cost::analytical::AnalyticalCostError| { + SelectionMilpError::Cost(error.to_string()) + }; + let request_ms = milp.cost_inputs.measured_operation_costs.ns_per_evaluation * 1e-6; + if request_ms > 0.0 { + for entry in milp.entries { + let requests = evaluation_rate([(&entry.recurrence, None)], horizon).map_err(cost)?; + milp.request_cost_per_second += unit_cost.cost_per_cpu_op * request_ms + / milp.cost_inputs.resource_speed.ms_per_cpu_op + * requests; + } + } + let (rates, query_rate) = context_rates(milp)?; + let memory = unit_cost.cost_per_memory_byte_second; + let mut objective: Vec<(Variable, f64)> = Vec::new(); + let mut charge = |expression: &Expression, coefficient: f64| { + if coefficient != 0.0 { + for (variable, factor) in expression.linear_coefficients() { + objective.push((variable, factor * coefficient)); + } + } + }; + // Per query-time shareable context: the execution cost of its runs + // other than `m`'s plain own term, and the most it can be. + let mut context_execution: BTreeMap = BTreeMap::new(); + // `H[m]` per query-time shareable context. + let mut held_time: HashMap = HashMap::new(); + let runs = milp.runs(); + for (context, candidate) in runs { + let terms = milp.run_terms(context, candidate); + milp.builder.scope = milp.scope_of(context); + let candidate_data = &milp.physical.candidates[candidate.0]; + let (timing, materialization) = (candidate_data.timing, candidate_data.materialization); + let own = milp.inputs.contexts[context].kind == ContextKind::Shared(candidate); + for term in &terms { + let numbers = term.numbers; + charge(&term.indicator, numbers.standing_cost); + match &rates[context] { + ContextRate::Fixed(rate) => charge(&term.indicator, numbers.execution_cost * rate), + ContextRate::Components(components) => { + if numbers.execution_cost == 0.0 { + } else if term.plain && own { + for &(component, rate) in components { + charge(&component.into(), numbers.execution_cost * rate); + } + } else { + let (sum, most) = context_execution + .entry(context) + .or_insert_with(|| (Expression::from(0.0), 0.0)); + *sum += numbers.execution_cost * term.indicator.clone(); + *most += numbers.execution_cost; + } + } + } + // Held memory. + if numbers.held_bytes <= 0.0 || timing != ExecutionTiming::QueryTime { + continue; + } + // Per byte held per latency unit. + let per_byte_ms = memory * numbers.held_bytes / 1_000.0 * milp.ms_per_latency_unit; + match milp.inputs.contexts[context].kind { + ContextKind::Query(query) => { + let latency = milp.latency[query]; + let most = milp.latency_max[query]; + let held = milp + .builder + .continuous("H_query (held for a query's latency)", None); + let indicator = term.indicator.clone(); + milp.builder.row( + "H_query (held for a query's latency)", + constraint!(held >= latency - most * (1.0 - indicator)), + ); + charge(&held.into(), per_byte_ms * query_rate[query]); + } + ContextKind::Shared(m) => { + if own && materialization != Materialization::MatAtQuery(Medium::Memory) { + milp.note_inexact("held bytes of a shareable candidate not in memory"); + } + let (longest, most_held) = *held_time.entry(context).or_insert_with(|| { + let longest = milp + .builder + .continuous("H (held until m's slowest reader)", None); + let mut most_held: f64 = 0.0; + for query in 0..milp.latency.len() { + if let Some(&reach) = milp.reach.get(&(query, m)) { + let latency = milp.latency[query]; + let most = milp.latency_max[query]; + most_held = most_held.max(most); + milp.builder.row( + "H (held until m's slowest reader)", + constraint!(longest >= latency - most * (1.0 - reach)), + ); + } + } + (longest, most_held) + }); + let components: Vec<(Option, f64)> = match &rates[context] { + ContextRate::Fixed(rate) => vec![(None, *rate)], + ContextRate::Components(components) => components + .iter() + .map(|&(component, rate)| (Some(component), rate)) + .collect(), + }; + for (component, rate) in components { + let mut both = Expression::from(0.0); + let mut slack = 0.0; + if let Some(component) = component { + both += component; + slack += 1.0; + } + if !(term.plain && own) { + both += term.indicator.clone(); + slack += 1.0; + } + if slack == 0.0 { + charge(&longest.into(), per_byte_ms * rate); + continue; + } + let held = milp + .builder + .continuous("W (held time × schedule group)", None); + milp.builder.row( + "W (held time × schedule group)", + constraint!(held >= longest - most_held * (slack - both)), + ); + charge(&held.into(), per_byte_ms * rate); + } + } + ContextKind::Ingestion => {} + } + } + } + // A context's execution at its varying rate: `V ≥ E − Ē (x[m] − z)` per + // component `z`. + milp.builder.scope = Scope::SharedContexts; + for (context, (sum, most)) in context_execution { + let ContextRate::Components(components) = &rates[context] else { + continue; + }; + let ContextKind::Shared(m) = milp.inputs.contexts[context].kind else { + continue; + }; + let region = milp + .builder + .continuous("V (context execution at a rate component)", None); + milp.builder.row( + "V (context execution at a rate component)", + constraint!(region >= sum), + ); + let active = milp.selected[&m]; + let slowest = components + .iter() + .map(|&(_, rate)| rate) + .fold(f64::INFINITY, f64::min); + let mut at_rates = Expression::from(0.0); + for &(component, rate) in components { + let at_rate = milp + .builder + .continuous("V (context execution at a rate component)", None); + milp.builder.row( + "V (context execution at a rate component)", + constraint!(at_rate >= region - most * (active - component)), + ); + charge(&at_rate.into(), rate); + at_rates += rate * at_rate; + } + if slowest.is_finite() && slowest > 0.0 { + milp.builder.row( + "V (slowest rate)", + constraint!(at_rates >= slowest * region), + ); + } + } + milp.builder.scope = Scope::Global; + raw_retention(milp, unit_cost.cost_per_disk_byte_second, &mut charge); + milp.objective_terms = objective; + Ok(()) +} + +/// Raw retention: per source, the largest raw data a query-time scan run of +/// the plan keeps, by levels. +fn raw_retention( + milp: &mut SelectionMilp<'_>, + disk: f64, + charge: &mut impl FnMut(&Expression, f64), +) { + let mut kept: BTreeMap> = BTreeMap::new(); + for (context, candidate) in milp.runs() { + let Operator::NonASAP(NonASAPOp::Scan { source, .. }) = + &milp.physical.physical_node(candidate).operator + else { + continue; + }; + if milp.physical.candidates[candidate.0].timing != ExecutionTiming::QueryTime { + continue; + } + let source = source_name(source).to_string(); + for term in milp.run_terms(context, candidate) { + let bytes = term.numbers.raw_kept_bytes; + if bytes > 0.0 { + kept.entry(source.clone()) + .or_default() + .push((bytes, term.indicator)); + } + } + } + for (_, scans) in kept { + let mut levels: Vec = scans.iter().map(|(bytes, _)| *bytes).collect(); + levels.sort_by(f64::total_cmp); + levels.dedup(); + let mut below = 0.0; + for level in levels { + let at_least = milp + .builder + .continuous("v (raw retention levels)", Some(1.0)); + for (bytes, indicator) in &scans { + if *bytes >= level { + let indicator = indicator.clone(); + milp.builder.row( + "v (raw retention levels)", + constraint!(at_least >= indicator), + ); + } + } + charge(&at_least.into(), disk * (level - below)); + below = level; + } + } +} diff --git a/crates/plan-selection/src/milp/decode.rs b/crates/plan-selection/src/milp/decode.rs new file mode 100644 index 000000000..dc62b8587 --- /dev/null +++ b/crates/plan-selection/src/milp/decode.rs @@ -0,0 +1,162 @@ +//! #1095 Step 6 (§2.0, "Decode and check"): the group selection MILP's raw +//! solution as a [`DagOfSelectedCandidates`], one node per candidate run. +//! +//! - A candidate computed in a context (`p[ctx, c] = 1`, a `NotMaterialized` +//! query-time candidate in a query's or a shareable candidate's context, +//! or a `NotMaterialized` ingestion-time candidate in `I`) is one node per +//! context: selected in two contexts, it is two nodes; read by two +//! readers in one context, it is one. +//! - A selected shareable candidate (`x[m] = 1`) is one node, whoever reads +//! it. +//! - In each context, input `n` of a computed node (or of `m`'s own node in +//! `m`'s context) reads the context's selected candidate among its +//! producers: the node computed there, the shareable candidate's node, or +//! the candidate's node in `I`. +//! - Output reference `r` is the selected output candidate in `r`'s query +//! context. +//! +//! Only the nodes the outputs reach are kept. The MILP never selects an +//! unread candidate (row 4), so this drops nothing at a solution whose +//! objective equals `plan_cost`. + +use std::collections::{BTreeMap, BTreeSet}; + +use asap_types::stages::{ + output_references, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, PhysicalCandidateId, + SelectedEdge, SelectedNodeId, +}; + +use crate::groups::{MilpInputs, ResultSlot, RunKind}; + +/// One run of a candidate in the solution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum Run { + /// Candidate `.1` computed in context `.0`. + Computed(usize, PhysicalCandidateId), + /// Shareable candidate `.0`. + Shared(PhysicalCandidateId), +} + +/// The selected candidates of a solution: per context, `p[ctx, c] = 1`, and +/// the shareable candidates with `x[m] = 1`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ChosenCandidates { + pub(crate) chosen: Vec>, + pub(crate) selected: BTreeSet, + /// Per multi-slot input `(context, reader, input)`, the slot it reads. + pub(crate) slot_reads: BTreeMap<(usize, PhysicalCandidateId, usize), ResultSlot>, +} + +/// The selection as a DAG of candidate runs over `physical` (module +/// documentation), or why it is not one (an output or an input with no +/// selected producer). The caller validates it +/// ([`DagOfSelectedCandidates::validate`]). +pub(crate) fn decode( + physical: &PhysicalASAPMemoCandidates, + inputs: &MilpInputs, + chosen: &ChosenCandidates, +) -> Result { + let ingestion = physical.query_roots.len(); + // The node a read of `producer` in `context` resolves to. + let run_of = |context: usize, producer: PhysicalCandidateId| -> Option { + if !chosen.chosen[context].contains(&producer) { + return None; + } + let kind = inputs.kind[producer.0]; + if inputs.contexts[context].computes(kind) { + return Some(Run::Computed(context, producer)); + } + match kind { + RunKind::Shareable => chosen + .selected + .contains(&producer) + .then_some(Run::Shared(producer)), + RunKind::Ingestion => chosen.chosen[ingestion] + .contains(&producer) + .then_some(Run::Computed(ingestion, producer)), + RunKind::PerContext => None, + } + }; + // The run's context for its inputs, and its candidate. + let inputs_of = |run: Run| -> Result, String> { + let (context, candidate) = match run { + Run::Computed(context, candidate) => (context, candidate), + Run::Shared(candidate) => ( + *inputs + .context_of_shareable + .get(&candidate) + .ok_or_else(|| format!("shareable candidate {} has no context", candidate.0))?, + candidate, + ), + }; + let mut reads = Vec::new(); + for (input, producers) in inputs.producer_candidates_per_input[candidate.0] + .iter() + .enumerate() + { + let slot_read = match run { + Run::Computed(..) => chosen.slot_reads.get(&(context, candidate, input)), + Run::Shared(_) => None, + }; + let producer = producers + .iter() + .filter(|producer| { + matches!(run, Run::Shared(_)) + || inputs.slot[producer.0] != inputs.slot[candidate.0] + }) + .filter(|producer| slot_read.is_none_or(|&slot| inputs.slot[producer.0] == slot)) + .find_map(|&producer| run_of(context, producer)) + .ok_or_else(|| { + format!( + "input {input} of candidate {} in context {context} has no selected producer", + candidate.0 + ) + })?; + reads.push((input, producer)); + } + Ok(reads) + }; + let outputs: Vec = output_references(&physical.query_roots) + .into_iter() + .enumerate() + .map(|(reference, (query, _))| { + inputs.output_candidates[reference] + .iter() + .find_map(|&candidate| run_of(query, candidate)) + .ok_or_else(|| format!("output reference {reference} has no selected candidate")) + }) + .collect::>()?; + // Number the runs the outputs reach, depth first from the outputs. + let mut node_of: BTreeMap = BTreeMap::new(); + let mut reads_of: BTreeMap> = BTreeMap::new(); + let mut candidate_of_node = Vec::new(); + let mut stack: Vec = outputs.iter().rev().copied().collect(); + while let Some(run) = stack.pop() { + if node_of.contains_key(&run) { + continue; + } + node_of.insert(run, SelectedNodeId(candidate_of_node.len())); + candidate_of_node.push(match run { + Run::Computed(_, candidate) | Run::Shared(candidate) => candidate, + }); + let reads = inputs_of(run)?; + stack.extend(reads.iter().map(|&(_, producer)| producer)); + reads_of.insert(run, reads); + } + let mut edges = Vec::new(); + for (run, &consumer) in &node_of { + for &(input, producer) in &reads_of[run] { + edges.push(SelectedEdge { + producer: node_of[&producer], + consumer, + input, + }); + } + } + edges.sort_by_key(|edge| (edge.consumer.0, edge.input)); + Ok(DagOfSelectedCandidates { + candidate_of_node, + edges, + node_of_output_reference: outputs.iter().map(|run| node_of[run]).collect(), + }) +} diff --git a/crates/plan-selection/src/milp/mod.rs b/crates/plan-selection/src/milp/mod.rs new file mode 100644 index 000000000..c0cf8b822 --- /dev/null +++ b/crates/plan-selection/src/milp/mod.rs @@ -0,0 +1,22 @@ +//! #1095 Steps 3–5: the group selection MILP (§2.0) over +//! [`MilpInputs`](crate::groups::MilpInputs): one candidate per result slot +//! per execution context, solved once with HiGHS from a warm start. +//! +//! - [`model`]: the variables and the structural rows (coverage, slots, +//! validity, used only if read, sharing, reach, reader constraints, +//! latency, accuracy), the solve and the raw solution; +//! - [`cost_terms`]: the cost model 1 objective, every coefficient a +//! number `asap-cost` computed (§3); +//! - [`warm_start`]: each query's cheapest plan, then greedy sharing, +//! HiGHS's first incumbent; +//! - [`completion`]: the exact objective of a plan given as binaries, the +//! cheapest values of the continuous variables found without a solver; +//! - [`decode`]: the solution as a `DagOfSelectedCandidates`, one node per +//! candidate run. +//! +//! [`crate::select`] runs them, realizes the plan once and checks it. +pub(crate) mod completion; +pub(crate) mod cost_terms; +pub(crate) mod decode; +pub(crate) mod model; +pub(crate) mod warm_start; diff --git a/crates/plan-selection/src/milp/model.rs b/crates/plan-selection/src/milp/model.rs new file mode 100644 index 000000000..f148955be --- /dev/null +++ b/crates/plan-selection/src/milp/model.rs @@ -0,0 +1,1860 @@ +//! #1095 §2.0: the group selection MILP's variables and rows over execution +//! contexts ([`crate::groups`]), one HiGHS solve, and the raw solution. +//! +//! ## Variables +//! +//! | Family | Meaning | +//! |---|---| +//! | `p[ctx, c]` | in context `ctx`, candidate `c` gives its result slot (binary): computed there, or read across the boundary (a shareable candidate, or a candidate of `I`) | +//! | `x[m]` | shareable candidate `m` is selected (binary) | +//! | `a[q, m]` | query `q` depends on shareable `m` (continuous, exact at integral binaries) | +//! | `y[q, m, c]` | `AND(a[q, m], p[m, c])`, for reach from above and accuracy (continuous) | +//! | `L[ctx]`, `T[q, m]`, `K[q]` | latency of a query or of a query-time shareable candidate's run and context; `m`'s latency in `q`'s; compaction (continuous) | +//! | `N[q]`, `A[q, parts]` | approximate values `q` depends on; whether it depends on one sized for `parts` shares (continuous) | +//! | `g[m, c]` | highest shared materialization: `c` is read in context `m`, which at most one query reaches (binary) | +//! | `rank` | order ranks on a context's cyclic links (continuous) | +//! +//! The cost families are [`super::cost_terms`]'s. +//! +//! ## Rows (numbered as in #1095 §2.0) +//! +//! 1. Coverage: `Σ_{c ∈ Out(r)} p[q(r), c] = 1` per output reference. +//! 2. One candidate per result slot per context: `Σ_{c ∈ slot} p[ctx, c] ≤ 1`. +//! 3. Validity: `Σ_{c' ∈ compat(c, n)} p[ctx, c'] ≥ p[ctx, c]` for each `c` +//! computed in `ctx`; `≥ x[m]` for `m`'s own inputs in its context; +//! `p[ctx, m] ≤ x[m]`, `p[ctx, c] ≤ p[I, c]` for reads across a +//! boundary. +//! 4. Used only if read, per context. +//! 5. Sharing: `x[m] ≤ Σ_ctx p[ctx, m]`. +//! 6. Reach: `a[q, m] ≥ p[q, m]`, `a[q, c] ≥ a[q, m] + p[m, c] − 1`; from +//! above, where a reader constraint counts queries. +//! 7. Latency (`plan_cost`'s: each query-time run it reaches counts once). +//! 8. Accuracy (A1), per run a query depends on. +//! 9. Reader constraints. +//! +//! A link from a candidate to one of its own result slot is dropped: with +//! one candidate per slot per context it can only be a self-loop (a +//! widened scan's `Project` lists its own memo group). Links inside a +//! larger cyclic component of a context get order ranks. +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::time::Instant; + +use asap_cost::{CostInputs, CostModel}; +use asap_types::stages::{ + output_references, PhysicalASAPMemoCandidates, PhysicalCandidateId, ReaderConstraint, +}; +use asap_types::workload::QueryWorkloadEntry; +use good_lp::solvers::highs::highs; +use good_lp::{ + constraint, variable, Constraint, Expression, IntoAffineExpression, ProblemVariables, + SolverModel, Variable, +}; + +use crate::groups::{AccuracyWeight, ContextKind, MilpInputs, ResultSlot, RunKind}; + +use super::cost_terms::{self, RunTerm}; +use super::warm_start; + +/// Why the selection MILP was not built or solved. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SelectionMilpError { + /// The MILP implements cost model 1 only (§3); the name of the model given. + UnsupportedCostModel(&'static str), + /// `entries` and the query roots differ in number. + EntryCount { roots: usize, entries: usize }, + /// No plan meets every row. + Infeasible, + /// HiGHS stopped (at the deadline) without an integral incumbent. + NoSolution { status: String }, + /// The deadline passed before the solve. + DeadlineReached, + /// A rate or another number `asap-cost` could not compute. + Cost(String), + /// HiGHS failed. + Solver(String), +} + +/// How the MILP is built. +#[derive(Debug, Clone, Copy)] +pub(crate) struct SelectionMilpOptions { + /// Write the accuracy rows (row 8); off when the facade ignores + /// accuracy guarantees. + pub(crate) enforce_accuracy: bool, +} + +/// Which part of the model a variable or row belongs to, for the size +/// report: the query contexts, `I`, the shareable candidates' contexts +/// (with their `x`, reach and schedule terms), or the rest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +pub(crate) enum Scope { + Queries, + Ingestion, + SharedContexts, + Global, +} + +/// The size of one family (or scope) of variables and rows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +pub(crate) struct FamilySize { + pub(crate) variables: usize, + pub(crate) binaries: usize, + pub(crate) rows: usize, + /// Nonzero coefficients of its rows. + pub(crate) nonzeros: usize, +} + +impl FamilySize { + fn add(&mut self, other: FamilySize) { + self.variables += other.variables; + self.binaries += other.binaries; + self.rows += other.rows; + self.nonzeros += other.nonzeros; + } +} + +/// Variables and rows, counted per family and per scope. +pub(crate) struct ModelBuilder { + pub(crate) variables: ProblemVariables, + pub(crate) rows: Vec, + /// The family of each row of `rows`. + pub(crate) row_families: Vec<&'static str>, + pub(crate) families: BTreeMap<&'static str, FamilySize>, + pub(crate) scopes: BTreeMap, + /// Where new variables and rows are counted. + pub(crate) scope: Scope, +} + +impl ModelBuilder { + fn new() -> Self { + Self { + variables: ProblemVariables::new(), + rows: Vec::new(), + row_families: Vec::new(), + families: BTreeMap::new(), + scopes: BTreeMap::new(), + scope: Scope::Global, + } + } + + fn count(&mut self, family: &'static str, size: FamilySize) { + self.families.entry(family).or_default().add(size); + self.scopes.entry(self.scope).or_default().add(size); + } + + pub(crate) fn binary(&mut self, family: &'static str) -> Variable { + self.count( + family, + FamilySize { + variables: 1, + binaries: 1, + ..FamilySize::default() + }, + ); + self.variables.add(variable().binary()) + } + + /// A continuous variable in `[0, max]` (unbounded above for `None`). + pub(crate) fn continuous(&mut self, family: &'static str, max: Option) -> Variable { + self.count( + family, + FamilySize { + variables: 1, + ..FamilySize::default() + }, + ); + let definition = variable().min(0.0); + self.variables.add(match max { + Some(max) => definition.max(max), + None => definition, + }) + } + + pub(crate) fn row(&mut self, family: &'static str, row: Constraint) { + let nonzeros = row.expression().linear_coefficients().count(); + self.count( + family, + FamilySize { + rows: 1, + nonzeros, + ..FamilySize::default() + }, + ); + self.rows.push(row); + self.row_families.push(family); + } + + /// `and ≥ Σ terms − (n − 1)`, `and ∈ [0, 1]`: the AND of `n` + /// indicators, tight under a cost or a `≤` row that charges it. + pub(crate) fn and_at_least(&mut self, family: &'static str, terms: &[Expression]) -> Variable { + let and = self.continuous(family, Some(1.0)); + let sum: Expression = terms.iter().cloned().sum(); + let count = terms.len() as f64; + self.row(family, constraint!(and >= sum - (count - 1.0))); + and + } +} + +/// One way a candidate's input is read in a context: `(reader, input)` +/// with the reader computed in that context, the query's output reference, +/// or the shareable candidate's own input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum Reader { + Candidate(PhysicalCandidateId, usize), + Output(usize), + OwnInput(usize), +} + +/// One input of a candidate: `(reader, input)`. +type InputOf = (PhysicalCandidateId, usize); + +/// The slot links of one multi-slot input: `(slot, e)`. +type SlotLinks = Vec<(ResultSlot, Variable)>; + +/// The built model, before the solve. +pub(crate) struct SelectionMilp<'a> { + pub(crate) builder: ModelBuilder, + pub(crate) physical: &'a PhysicalASAPMemoCandidates, + pub(crate) inputs: &'a MilpInputs, + pub(crate) entries: &'a [QueryWorkloadEntry], + pub(crate) cost_inputs: &'a CostInputs, + /// Per context, `p[ctx, c]` per option. + pub(crate) choice: Vec>, + /// Per context, per option, the reads of it in that context. + pub(crate) readers: Vec>>, + /// `x[m]` per shareable candidate with a context. + pub(crate) selected: BTreeMap, + /// `a[q, m]`, for each query that may depend on shareable `m`. + pub(crate) reach: HashMap<(usize, PhysicalCandidateId), Variable>, + /// Per shareable candidate, the shareable candidates whose contexts may + /// read it. + pub(crate) shared_readers: BTreeMap>, + /// `L[q]`. + pub(crate) latency: Vec, + /// An upper bound of each `L[q]` (its bound, or the most it can be), in + /// latency units. + pub(crate) latency_max: Vec, + /// Milliseconds per latency unit: every latency variable (`L`, `T`, `K` + /// and the held times) counts in these units, chosen so the largest + /// possible latency is about 1,000 units (with sub-microsecond work per + /// execution, milliseconds fall below HiGHS's absolute tolerances). + pub(crate) ms_per_latency_unit: f64, + /// The cost model 1 objective, in cost per second, without the request + /// fees (constant for every plan, added after selection). + pub(crate) objective_terms: Vec<(Variable, f64)>, + /// The request fees per second (the same for every plan). + pub(crate) request_cost_per_second: f64, + /// Terms encoded inexactly, by reason. + pub(crate) inexact: BTreeMap<&'static str, usize>, + /// The terms of each run `(context, candidate)`, cached; a shareable + /// candidate's own run is keyed by its own context. + pub(crate) run_terms_cache: HashMap<(usize, PhysicalCandidateId), Vec>, + /// Runs whose terms are being built (cycles of links). + pub(crate) terms_in_progress: HashSet<(usize, PhysicalCandidateId)>, + /// Per run and outputs, the variable `≥` its terms giving them. + pub(crate) producer_gives: HashMap<(usize, PhysicalCandidateId, Vec), Variable>, + /// Per query-time scan run, its window terms + /// ([`cost_terms::window_terms`]). + pub(crate) window_cache: HashMap<(usize, PhysicalCandidateId), Option>>, + /// For each input whose producers lie in several result slots (a + /// factored root over the private nodes of several implementations), + /// per context: `e[ctx, r, n, slot]`, the slot input `n` of computed + /// reader `r` reads (binary). Row 4 counts these reads, so a selected + /// producer in the other slot is not justified by `r`. + pub(crate) slot_links: + BTreeMap<(usize, PhysicalCandidateId, usize), Vec<(ResultSlot, Variable)>>, +} + +/// Variables and rows of one model. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub(crate) struct SelectionMilpSize { + pub(crate) variables: usize, + pub(crate) binaries: usize, + pub(crate) rows: usize, + pub(crate) nonzeros: usize, + pub(crate) families: BTreeMap<&'static str, FamilySize>, + pub(crate) scopes: BTreeMap, + pub(crate) inexact: BTreeMap<&'static str, usize>, +} + +/// How the solve ended with a plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SelectionMilpStatus { + Optimal, + /// HiGHS's time limit (the deadline), with an integral incumbent. + TimeLimit, +} + +/// The raw solution, ready to decode. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SelectionMilpSolution { + pub(crate) status: SelectionMilpStatus, + /// The objective: cost per second without the request fees. + pub(crate) cost_per_second: f64, + /// The request fees per second, the same for every plan. + pub(crate) request_cost_per_second: f64, + /// HiGHS's dual bound on `cost_per_second`. + pub(crate) cost_lower_bound_per_second: f64, + /// HiGHS's relative gap. + pub(crate) relative_gap: f64, + /// Per context, the options with `p[ctx, c] = 1`. + pub(crate) chosen: Vec>, + /// The shareable candidates with `x[m] = 1`. + pub(crate) selected: BTreeSet, + /// Per multi-slot input `(context, reader, input)` of a used reader, the + /// slot it reads. + pub(crate) slot_reads: BTreeMap<(usize, PhysicalCandidateId, usize), ResultSlot>, + pub(crate) size: SelectionMilpSize, + pub(crate) build_ms: u64, + /// Building the warm start, HiGHS's first incumbent. + pub(crate) warm_start_ms: u64, + /// The warm start's objective (like `cost_per_second`), when its + /// completion costed it. + pub(crate) warm_start_cost_per_second: Option, + pub(crate) solve_ms: u64, +} + +impl<'a> SelectionMilp<'a> { + pub(crate) fn query_count(&self) -> usize { + self.physical.query_roots.len() + } + + /// The index of `I` among the contexts. + pub(crate) fn ingestion_context(&self) -> usize { + self.query_count() + } + + /// The context of shareable candidate `m`. + pub(crate) fn context_of(&self, shareable: PhysicalCandidateId) -> Option { + self.inputs.context_of_shareable.get(&shareable).copied() + } + + pub(crate) fn kind(&self, candidate: PhysicalCandidateId) -> RunKind { + self.inputs.kind[candidate.0] + } + + pub(crate) fn slot(&self, candidate: PhysicalCandidateId) -> ResultSlot { + self.inputs.slot[candidate.0] + } + + /// Whether `candidate` is computed in context `context`. + pub(crate) fn computes(&self, context: usize, candidate: PhysicalCandidateId) -> bool { + self.inputs.contexts[context].computes(self.kind(candidate)) + } + + /// The indicator of run `(context, candidate)`: `p[ctx, c]` for a + /// candidate computed there, `x[m]` for a shareable candidate's own run + /// in its own context. + pub(crate) fn run_indicator( + &self, + context: usize, + candidate: PhysicalCandidateId, + ) -> Option { + if self.inputs.contexts[context].kind == ContextKind::Shared(candidate) { + return self.selected.get(&candidate).copied(); + } + if !self.computes(context, candidate) { + return None; + } + self.choice[context].get(&candidate).copied() + } + + /// The context where run `(context, candidate)` reads its inputs: its + /// own (a shareable candidate's own run reads in its context too). + pub(crate) fn runs_at_query_time(&self, context: usize) -> bool { + self.inputs.contexts[context].at_query_time + } + + pub(crate) fn size(&self) -> SelectionMilpSize { + let families = self.builder.families.clone(); + SelectionMilpSize { + variables: families.values().map(|f| f.variables).sum(), + binaries: families.values().map(|f| f.binaries).sum(), + rows: families.values().map(|f| f.rows).sum(), + nonzeros: families.values().map(|f| f.nonzeros).sum(), + families, + scopes: self.builder.scopes.clone(), + inexact: self.inexact.clone(), + } + } + + pub(crate) fn note_inexact(&mut self, reason: &'static str) { + *self.inexact.entry(reason).or_default() += 1; + } + + /// The scope of context `context`. + pub(crate) fn scope_of(&self, context: usize) -> Scope { + match self.inputs.contexts[context].kind { + ContextKind::Query(_) => Scope::Queries, + ContextKind::Ingestion => Scope::Ingestion, + ContextKind::Shared(_) => Scope::SharedContexts, + } + } + + /// Run `(context, candidate)`'s terms ([`cost_terms::run_terms`]), + /// cached. + pub(crate) fn run_terms( + &mut self, + context: usize, + candidate: PhysicalCandidateId, + ) -> Vec { + if let Some(terms) = self.run_terms_cache.get(&(context, candidate)) { + return terms.clone(); + } + let scope_here = self.scope_of(context); + let scope = std::mem::replace(&mut self.builder.scope, scope_here); + let terms = cost_terms::run_terms(self, context, candidate); + self.builder.scope = scope; + self.run_terms_cache + .insert((context, candidate), terms.clone()); + terms + } + + /// Every run of the model: `(context, candidate)` for each candidate + /// computed in a context, and each shareable candidate's own run. + pub(crate) fn runs(&self) -> Vec<(usize, PhysicalCandidateId)> { + let mut runs = Vec::new(); + for (context, options) in self.choice.iter().enumerate() { + for &candidate in options.keys() { + if self.computes(context, candidate) { + runs.push((context, candidate)); + } + } + if let ContextKind::Shared(own) = self.inputs.contexts[context].kind { + if self.selected.contains_key(&own) { + runs.push((context, own)); + } + } + } + runs + } + + /// The query contexts that may depend on shareable `m`, ascending. + pub(crate) fn queries_reaching(&self, shareable: PhysicalCandidateId) -> Vec { + let mut queries: Vec = (0..self.query_count()) + .filter(|&query| self.reach.contains_key(&(query, shareable))) + .collect(); + queries.sort_unstable(); + queries + } +} + +/// Builds the MILP over `inputs` (§2.0): `entries[q]` is query `q`'s +/// workload entry. +pub(crate) fn build_selection_milp<'a>( + physical: &'a PhysicalASAPMemoCandidates, + inputs: &'a MilpInputs, + entries: &'a [QueryWorkloadEntry], + cost_inputs: &'a CostInputs, + options: SelectionMilpOptions, +) -> Result, SelectionMilpError> { + match cost_inputs.cost_model { + CostModel::UsageOverTime(_) => {} + CostModel::ProvisionedCapacity(_) => { + return Err(SelectionMilpError::UnsupportedCostModel( + "ProvisionedCapacity (cost model 2)", + )) + } + CostModel::ClusterCapacity(_) => { + return Err(SelectionMilpError::UnsupportedCostModel( + "ClusterCapacity (cost model 3)", + )) + } + } + let query_count = physical.query_roots.len(); + if entries.len() != query_count { + return Err(SelectionMilpError::EntryCount { + roots: query_count, + entries: entries.len(), + }); + } + let mut milp = SelectionMilp { + builder: ModelBuilder::new(), + physical, + inputs, + entries, + cost_inputs, + choice: Vec::new(), + readers: Vec::new(), + selected: BTreeMap::new(), + reach: HashMap::new(), + shared_readers: BTreeMap::new(), + latency: Vec::new(), + latency_max: Vec::new(), + ms_per_latency_unit: 1.0, + objective_terms: Vec::new(), + request_cost_per_second: 0.0, + inexact: BTreeMap::new(), + run_terms_cache: HashMap::new(), + terms_in_progress: HashSet::new(), + producer_gives: HashMap::new(), + window_cache: HashMap::new(), + slot_links: BTreeMap::new(), + }; + choice_variables(&mut milp); + structural_rows(&mut milp); + acyclic_rows(&mut milp); + acyclic_context_rows(&mut milp); + reach_rows(&mut milp); + reader_constraint_rows(&mut milp); + latency_rows(&mut milp)?; + if options.enforce_accuracy { + accuracy_rows(&mut milp); + } + cost_terms::encode_objective(&mut milp)?; + Ok(milp) +} + +/// `p`, `x` and, per context, the reads of each option. +fn choice_variables(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + let output_refs = output_references(&milp.physical.query_roots); + for (context, data) in inputs.contexts.iter().enumerate() { + milp.builder.scope = milp.scope_of(context); + let options: BTreeSet = data.options.iter().copied().collect(); + let mut readers: BTreeMap> = BTreeMap::new(); + let mut choices = BTreeMap::new(); + for &option in &options { + choices.insert(option, milp.builder.binary("p (choices)")); + } + for &reader in &options { + if !data.computes(inputs.kind[reader.0]) { + continue; + } + for (input, producers) in inputs.producer_candidates_per_input[reader.0] + .iter() + .enumerate() + { + for &producer in producers { + // A read of its own result slot is a self-loop. + if options.contains(&producer) + && inputs.slot[producer.0] != inputs.slot[reader.0] + { + readers + .entry(producer) + .or_default() + .push(Reader::Candidate(reader, input)); + } + } + } + } + match data.kind { + ContextKind::Query(query) => { + for (reference, (q, _)) in output_refs.iter().enumerate() { + if *q != query { + continue; + } + for &out in &inputs.output_candidates[reference] { + if options.contains(&out) { + readers + .entry(out) + .or_default() + .push(Reader::Output(reference)); + } + } + } + } + ContextKind::Shared(own) => { + for (input, producers) in inputs.producer_candidates_per_input[own.0] + .iter() + .enumerate() + { + for &producer in producers { + if options.contains(&producer) { + readers + .entry(producer) + .or_default() + .push(Reader::OwnInput(input)); + } + } + } + } + ContextKind::Ingestion => {} + } + for &reader in &options { + if !data.computes(inputs.kind[reader.0]) { + continue; + } + for (input, producers) in inputs.producer_candidates_per_input[reader.0] + .iter() + .enumerate() + { + let slots: BTreeSet = producers + .iter() + .filter(|producer| options.contains(producer)) + .map(|producer| inputs.slot[producer.0]) + .filter(|&slot| slot != inputs.slot[reader.0]) + .collect(); + if slots.len() > 1 { + let links = slots + .into_iter() + .map(|slot| (slot, milp.builder.binary("e (slot an input reads)"))) + .collect(); + milp.slot_links.insert((context, reader, input), links); + } + } + } + milp.choice.push(choices); + milp.readers.push(readers); + } + milp.builder.scope = Scope::SharedContexts; + for &shareable in inputs.context_of_shareable.keys() { + let selected = milp.builder.binary("x (shareable candidates)"); + milp.selected.insert(shareable, selected); + } + for (context, data) in inputs.contexts.iter().enumerate() { + if let ContextKind::Shared(reader) = data.kind { + for &option in &data.options { + if inputs.kind[option.0] == RunKind::Shareable { + milp.shared_readers.entry(option).or_default().push(reader); + } + } + let _ = context; + } + } + milp.builder.scope = Scope::Global; +} + +/// Rows 1–5. +fn structural_rows(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + let output_refs = output_references(&milp.physical.query_roots); + let ingestion = milp.ingestion_context(); + // 1. Coverage. + milp.builder.scope = Scope::Queries; + for (reference, (query, _)) in output_refs.iter().enumerate() { + let sum: Expression = inputs.output_candidates[reference] + .iter() + .filter_map(|out| milp.choice[*query].get(out).copied()) + .map(Expression::from) + .sum(); + milp.builder.row("1 coverage", constraint!(sum == 1.0)); + } + for context in 0..inputs.contexts.len() { + milp.builder.scope = milp.scope_of(context); + let data = &inputs.contexts[context]; + // 2. One candidate per slot. + let mut per_slot: BTreeMap> = BTreeMap::new(); + for (&option, &variable) in &milp.choice[context] { + per_slot + .entry(inputs.slot[option.0]) + .or_default() + .push(variable); + } + for variables in per_slot.values().filter(|v| v.len() > 1) { + let sum: Expression = variables.iter().copied().map(Expression::from).sum(); + milp.builder + .row("2 one candidate per slot", constraint!(sum <= 1.0)); + } + // 3. Validity. + let choices = milp.choice[context].clone(); + let producers_sum = |reader: PhysicalCandidateId, input: usize| -> Expression { + inputs.producer_candidates_per_input[reader.0][input] + .iter() + .filter(|producer| inputs.slot[producer.0] != inputs.slot[reader.0]) + .filter_map(|producer| choices.get(producer).copied()) + .map(Expression::from) + .sum() + }; + for (&option, &variable) in &choices { + let kind = inputs.kind[option.0]; + if data.computes(kind) { + for input in 0..inputs.producer_candidates_per_input[option.0].len() { + let sum = producers_sum(option, input); + milp.builder.row("3 validity", constraint!(sum >= variable)); + } + } else { + match kind { + RunKind::Shareable => { + let selected = milp.selected[&option]; + milp.builder + .row("3 validity (boundary)", constraint!(variable <= selected)); + } + RunKind::Ingestion => { + let source = milp.choice[ingestion].get(&option).copied(); + match source { + Some(source) => milp + .builder + .row("3 validity (boundary)", constraint!(variable <= source)), + None => milp + .builder + .row("3 validity (boundary)", constraint!(variable <= 0.0)), + } + } + RunKind::PerContext => milp + .builder + .row("3 validity (boundary)", constraint!(variable <= 0.0)), + } + } + } + if let ContextKind::Shared(own) = data.kind { + let selected = milp.selected[&own]; + for input in 0..inputs.producer_candidates_per_input[own.0].len() { + let sum: Expression = inputs.producer_candidates_per_input[own.0][input] + .iter() + .filter_map(|producer| choices.get(producer).copied()) + .map(Expression::from) + .sum(); + milp.builder.row("3 validity", constraint!(sum >= selected)); + } + } + // The slot each multi-slot input reads: one, and only a slot the + // context gives. + let links: Vec<(InputOf, SlotLinks)> = milp + .slot_links + .range((context, PhysicalCandidateId(0), 0)..(context + 1, PhysicalCandidateId(0), 0)) + .map(|(&(_, reader, input), links)| ((reader, input), links.clone())) + .collect(); + for ((reader, input), links) in links { + let used = choices[&reader]; + let sum: Expression = links.iter().map(|&(_, link)| Expression::from(link)).sum(); + milp.builder + .row("3 validity (slot links)", constraint!(sum == used)); + for (slot, link) in links { + let given: Expression = inputs.producer_candidates_per_input[reader.0][input] + .iter() + .filter(|producer| inputs.slot[producer.0] == slot) + .filter_map(|producer| choices.get(producer).copied()) + .map(Expression::from) + .sum(); + milp.builder + .row("3 validity (slot links)", constraint!(link <= given)); + } + } + // 4. Used only if read. + for (&option, &variable) in &choices { + let mut reads = Expression::from(0.0); + for reader in milp.readers[context].get(&option).into_iter().flatten() { + match *reader { + Reader::Candidate(reader, input) => { + match milp.slot_links.get(&(context, reader, input)) { + Some(links) => { + let slot = inputs.slot[option.0]; + for &(at, link) in links { + if at == slot { + reads += link; + } + } + } + None => reads += choices[&reader], + } + } + Reader::Output(_) => reads += 1.0, + Reader::OwnInput(_) => { + if let ContextKind::Shared(own) = data.kind { + reads += milp.selected[&own]; + } + } + } + } + if data.kind == ContextKind::Ingestion { + for other in 0..inputs.contexts.len() { + if other != context { + if let Some(&read) = milp.choice[other].get(&option) { + reads += read; + } + } + } + } + milp.builder + .row("4 used only if read", constraint!(variable <= reads)); + } + } + // 5. Sharing. + milp.builder.scope = Scope::SharedContexts; + let mut reads_of: BTreeMap = BTreeMap::new(); + for choices in &milp.choice { + for (&option, &variable) in choices { + if inputs.kind[option.0] == RunKind::Shareable { + *reads_of.entry(option).or_default() += variable; + } + } + } + let selected = milp.selected.clone(); + for (shareable, selected) in selected { + let reads = reads_of.remove(&shareable).unwrap_or_default(); + milp.builder + .row("5 sharing", constraint!(selected <= reads)); + } + milp.builder.scope = Scope::Global; +} + +/// Order ranks on the links inside each cyclic component of a context's +/// links between candidates it computes (none when the slot graph is +/// acyclic). +fn acyclic_rows(milp: &mut SelectionMilp<'_>) { + for context in 0..milp.choice.len() { + milp.builder.scope = milp.scope_of(context); + let computed: Vec = milp.choice[context] + .keys() + .copied() + .filter(|&c| milp.computes(context, c)) + .collect(); + let index: HashMap = + computed.iter().enumerate().map(|(i, &c)| (c, i)).collect(); + let mut edges: Vec<(usize, usize)> = Vec::new(); + for (&producer, readers) in &milp.readers[context] { + let Some(&from) = index.get(&producer) else { + continue; + }; + for reader in readers { + if let Reader::Candidate(reader, _) = reader { + edges.push((from, index[reader])); + } + } + } + let components = strongly_connected_components(computed.len(), &edges); + let mut rank: HashMap = HashMap::new(); + for &(producer, reader) in &edges { + let size = components.size_of(reader); + if components.of[producer] != components.of[reader] || size < 2 { + continue; + } + let n = size as f64; + let [producer_rank, reader_rank] = [producer, reader].map(|node| { + *rank + .entry(node) + .or_insert_with(|| milp.builder.continuous("acyclic ranks", Some(n - 1.0))) + }); + // Both used: the reader ranks above the producer. (Within one + // context a used reader reads the used candidate of the slot.) + let (p, r) = ( + milp.choice[context][&computed[producer]], + milp.choice[context][&computed[reader]], + ); + milp.builder.row( + "acyclic ranks", + constraint!(producer_rank + 1.0 <= reader_rank + n * (2.0 - p - r)), + ); + } + } + milp.builder.scope = Scope::Global; +} + +/// Order ranks across contexts: on the graph whose nodes are the shareable +/// candidates' own runs and the runs of `I`, with an edge from a run to +/// each shareable candidate or candidate of `I` its context may read (for +/// a run of `I`, its producers in `I`). Rows 3–5 alone admit a cycle of +/// reads across contexts that justifies itself with no output above it +/// (two shareable candidates of one memo group, each listed among the +/// other's producers); only edges inside a cyclic component get a row. +fn acyclic_context_rows(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + let ingestion = milp.ingestion_context(); + // Nodes: (context of the run, candidate). + let mut nodes: Vec<(usize, PhysicalCandidateId)> = Vec::new(); + for (&m, &context) in &inputs.context_of_shareable { + nodes.push((context, m)); + } + for &c in milp.choice[ingestion].keys() { + if milp.computes(ingestion, c) { + nodes.push((ingestion, c)); + } + } + let index: HashMap = nodes + .iter() + .enumerate() + .map(|(i, &(_, c))| (c, i)) + .collect(); + // Edges with their activity: `(from, to, indicators that must all be 1)`. + let mut edges: Vec<(usize, usize, Vec)> = Vec::new(); + for (from, &(context, candidate)) in nodes.iter().enumerate() { + if context == ingestion { + let reader = milp.choice[ingestion][&candidate]; + for producers in &inputs.producer_candidates_per_input[candidate.0] { + for producer in producers { + if let (Some(&to), Some(&read)) = + (index.get(producer), milp.choice[ingestion].get(producer)) + { + if to != from { + edges.push((from, to, vec![reader, read])); + } + } + } + } + } else { + for (&option, &read) in &milp.choice[context] { + if let Some(&to) = index.get(&option) { + if inputs.kind[option.0] != RunKind::PerContext { + edges.push((from, to, vec![read])); + } + } + } + } + } + let plain: Vec<(usize, usize)> = edges.iter().map(|&(from, to, _)| (from, to)).collect(); + let components = strongly_connected_components(nodes.len(), &plain); + milp.builder.scope = Scope::SharedContexts; + let mut rank: HashMap = HashMap::new(); + for (from, to, active) in edges { + let size = components.size_of(from); + if components.of[from] != components.of[to] || size < 2 { + continue; + } + let n = size as f64; + let [reader_rank, read_rank] = [from, to].map(|node| { + *rank.entry(node).or_insert_with(|| { + milp.builder + .continuous("acyclic ranks (contexts)", Some(n - 1.0)) + }) + }); + let slack = active.len() as f64; + let on: Expression = active.into_iter().map(Expression::from).sum(); + milp.builder.row( + "acyclic ranks (contexts)", + constraint!(read_rank + 1.0 <= reader_rank + n * (slack - on)), + ); + } + milp.builder.scope = Scope::Global; +} + +/// The strongly connected components of a directed graph. +pub(crate) struct Components { + /// Per node, its component. + pub(crate) of: Vec, + /// Per component, its number of nodes. + pub(crate) sizes: Vec, +} + +impl Components { + pub(crate) fn size_of(&self, node: usize) -> usize { + self.sizes[self.of[node]] + } +} + +/// The strongly connected components of the graph on nodes `0..count` with +/// `edges` `(from, to)` (Tarjan's algorithm, without recursion). +pub(crate) fn strongly_connected_components(count: usize, edges: &[(usize, usize)]) -> Components { + let mut successors = vec![Vec::new(); count]; + for &(from, to) in edges { + successors[from].push(to); + } + const UNVISITED: usize = usize::MAX; + let mut order = vec![UNVISITED; count]; + let mut lowest = vec![0; count]; + let mut on_stack = vec![false; count]; + let mut stack = Vec::new(); + let mut of = vec![UNVISITED; count]; + let mut sizes = Vec::new(); + let mut next = 0; + for root in 0..count { + if order[root] != UNVISITED { + continue; + } + let mut path = vec![(root, 0)]; + order[root] = next; + lowest[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + while let Some(&mut (node, ref mut successor)) = path.last_mut() { + if let Some(&to) = successors[node].get(*successor) { + *successor += 1; + if order[to] == UNVISITED { + order[to] = next; + lowest[to] = next; + next += 1; + stack.push(to); + on_stack[to] = true; + path.push((to, 0)); + } else if on_stack[to] { + lowest[node] = lowest[node].min(order[to]); + } + continue; + } + path.pop(); + if let Some(&(parent, _)) = path.last() { + lowest[parent] = lowest[parent].min(lowest[node]); + } + if lowest[node] == order[node] { + let component = sizes.len(); + let mut size = 0; + loop { + let member = stack.pop().expect("the node is on the stack"); + on_stack[member] = false; + of[member] = component; + size += 1; + if member == node { + break; + } + } + sizes.push(size); + } + } + } + Components { of, sizes } +} + +/// The shareable candidates in order: each before every shareable +/// candidate its context may read (readers first), so `a[q, m]` exists +/// before the reach rows of what `m` reads. A cycle among contexts is +/// broken at the first candidate met. +pub(crate) fn shareables_readers_first(milp: &SelectionMilp<'_>) -> Vec { + let inputs = milp.inputs; + let mut order = Vec::new(); + let mut state: BTreeMap = BTreeMap::new(); + // Post-order over "m reads c" (children = what m reads), reversed. + for &start in inputs.context_of_shareable.keys() { + if state.contains_key(&start) { + continue; + } + let mut stack: Vec<(PhysicalCandidateId, bool)> = vec![(start, false)]; + while let Some((node, done)) = stack.pop() { + if done { + state.insert(node, 2); + order.push(node); + continue; + } + if state.contains_key(&node) { + continue; + } + state.insert(node, 1); + stack.push((node, true)); + let context = inputs.context_of_shareable[&node]; + for &option in &inputs.contexts[context].options { + if inputs.kind[option.0] == RunKind::Shareable && !state.contains_key(&option) { + stack.push((option, false)); + } + } + } + } + order.reverse(); + order +} + +/// Row 6: reach, from below for every `(q, m)`, from above where a reader +/// constraint counts queries. +fn reach_rows(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + milp.builder.scope = Scope::SharedContexts; + let order = shareables_readers_first(milp); + // Which queries may depend on each shareable candidate: read in their + // context, or in the context of one they may depend on. + let mut may_reach: BTreeMap> = BTreeMap::new(); + for query in 0..milp.query_count() { + for &option in &inputs.contexts[query].options { + if inputs.kind[option.0] == RunKind::Shareable { + may_reach.entry(option).or_default().insert(query); + } + } + } + for &shareable in &order { + let queries = may_reach.get(&shareable).cloned().unwrap_or_default(); + let context = inputs.context_of_shareable[&shareable]; + for &option in &inputs.contexts[context].options { + if inputs.kind[option.0] == RunKind::Shareable { + may_reach + .entry(option) + .or_default() + .extend(queries.iter().copied()); + } + } + } + for &shareable in &order { + for &query in may_reach.get(&shareable).into_iter().flatten() { + let reach = milp.builder.continuous("a (reach)", Some(1.0)); + milp.reach.insert((query, shareable), reach); + } + } + for &shareable in &order { + for &query in may_reach.get(&shareable).into_iter().flatten() { + let reach = milp.reach[&(query, shareable)]; + if let Some(&direct) = milp.choice[query].get(&shareable) { + milp.builder + .row("6 reach from below", constraint!(reach >= direct)); + } + for &reader in milp.shared_readers.get(&shareable).into_iter().flatten() { + let Some(&above) = milp.reach.get(&(query, reader)) else { + continue; + }; + let context = inputs.context_of_shareable[&reader]; + let read = milp.choice[context][&shareable]; + milp.builder.row( + "6 reach from below", + constraint!(reach >= above + read - 1.0), + ); + } + } + } + // From above, where needed. + let exact = shareables_needing_exact_reach(milp); + for &shareable in &order { + if !exact.contains(&shareable) { + continue; + } + for &query in may_reach.get(&shareable).into_iter().flatten() { + let reach = milp.reach[&(query, shareable)]; + let mut sources = Expression::from(0.0); + if let Some(&direct) = milp.choice[query].get(&shareable) { + sources += direct; + } + for &reader in milp + .shared_readers + .get(&shareable) + .cloned() + .unwrap_or_default() + .iter() + { + let Some(&above) = milp.reach.get(&(query, reader)) else { + continue; + }; + let context = inputs.context_of_shareable[&reader]; + let read = milp.choice[context][&shareable]; + let through = milp.builder.continuous("y (reach through)", Some(1.0)); + milp.builder + .row("6 reach from above", constraint!(through <= above)); + milp.builder + .row("6 reach from above", constraint!(through <= read)); + sources += through; + } + milp.builder + .row("6 reach from above", constraint!(reach <= sources)); + } + } + milp.builder.scope = Scope::Global; +} + +/// The reader constraints of each candidate. +pub(crate) fn reader_constraints_of_candidates<'a>( + milp: &SelectionMilp<'a>, +) -> BTreeMap> { + let physical: &'a PhysicalASAPMemoCandidates = milp.physical; + let mut of_candidate: BTreeMap> = + BTreeMap::new(); + for constraint in &physical.reader_constraints { + of_candidate + .entry(constraint.candidate()) + .or_default() + .push(constraint); + } + of_candidate +} + +/// The shareable candidates whose `a` must be exact from above: those a +/// counting reader constraint (minimum readers, highest shared +/// materialization) names or whose context holds a constrained candidate, +/// and transitively every shareable candidate whose context reads one. +fn shareables_needing_exact_reach(milp: &SelectionMilp<'_>) -> BTreeSet { + let inputs = milp.inputs; + let constraints = reader_constraints_of_candidates(milp); + let mut stack: Vec = Vec::new(); + for (candidate, constraints) in &constraints { + let counted = constraints.iter().any(|constraint| { + matches!( + constraint, + ReaderConstraint::MinimumQueryReaders { .. } + | ReaderConstraint::HighestSharedQueryMaterialization { .. } + ) + }); + if !counted { + continue; + } + if inputs.kind[candidate.0] == RunKind::Shareable { + stack.push(*candidate); + // Highest shared materialization reads the reach of its + // reading contexts. + stack.extend(milp.shared_readers.get(candidate).into_iter().flatten()); + } + for (context, data) in inputs.contexts.iter().enumerate() { + if let ContextKind::Shared(own) = data.kind { + if milp.choice[context].contains_key(candidate) { + stack.push(own); + } + } + } + } + let mut exact = BTreeSet::new(); + while let Some(shareable) = stack.pop() { + if exact.insert(shareable) { + stack.extend(milp.shared_readers.get(&shareable).into_iter().flatten()); + } + } + exact +} + +/// The queries of `query_outputs` buckets (one per query, each its output +/// references). +fn bucket_queries(query_of_reference: &[usize], query_outputs: &[Vec]) -> Vec { + query_outputs + .iter() + .filter_map(|bucket| { + bucket + .first() + .map(|&reference| query_of_reference[reference]) + }) + .collect() +} + +/// Row 9: reader constraints, on every node of the constrained candidate. +/// A node in a query's context is reached by that query alone; a node in +/// shareable `m`'s context, and `m` itself, by the queries that depend on +/// `m`. Reads into `I` and per-output-reference reach inside one query +/// are not modeled: the constraint is then applied to every reference of +/// the query (conservative, counted as inexact). +fn reader_constraint_rows(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + let query_of_reference: Vec = output_references(&milp.physical.query_roots) + .into_iter() + .map(|(query, _)| query) + .collect(); + let constraints = reader_constraints_of_candidates(milp); + let query_count = milp.query_count(); + milp.builder.scope = Scope::Global; + let reach_count = |milp: &SelectionMilp<'_>, queries: &[usize], m: PhysicalCandidateId| { + let mut count = Expression::from(0.0); + let mut most = 0.0f64; + for &query in queries { + if let Some(&reach) = milp.reach.get(&(query, m)) { + count += reach; + most += 1.0; + } + } + (count, most) + }; + for (candidate, constraints) in constraints { + let shareable = inputs.kind[candidate.0] == RunKind::Shareable; + // Its uses: (context, p) per context holding it as an option it + // computes. + let uses: Vec<(usize, Variable)> = (0..milp.choice.len()) + .filter(|&context| milp.computes(context, candidate)) + .filter_map(|context| { + milp.choice[context] + .get(&candidate) + .map(|&variable| (context, variable)) + }) + .collect(); + for constraint in constraints { + match constraint { + ReaderConstraint::MinimumQueryReaders { + query_outputs, + minimum, + .. + } => { + let queries = bucket_queries(&query_of_reference, query_outputs); + let minimum = *minimum as f64; + if shareable { + if let Some(&selected) = milp.selected.get(&candidate) { + let (count, _) = reach_count(milp, &queries, candidate); + milp.builder.row( + "9 reader constraints", + constraint!(count >= minimum * selected), + ); + } + } + for &(context, used) in &uses { + let count = match inputs.contexts[context].kind { + ContextKind::Query(_) => Expression::from(1.0), + ContextKind::Shared(m) => reach_count(milp, &queries, m).0, + ContextKind::Ingestion => { + milp.note_inexact( + "minimum readers of a candidate of I (forbidden)", + ); + Expression::from(0.0) + } + }; + milp.builder + .row("9 reader constraints", constraint!(count >= minimum * used)); + } + } + ReaderConstraint::HighestSharedQueryMaterialization { query_outputs, .. } => { + let queries = bucket_queries(&query_of_reference, query_outputs); + if shareable { + let Some(&selected) = milp.selected.get(&candidate) else { + continue; + }; + // Allowed: a read in a query's context (its readers + // there are reached by that query alone), or a read + // in the context of `m` that at most one query + // reaches. + let mut allowed = Expression::from(0.0); + for query in 0..query_count { + if let Some(&read) = milp.choice[query].get(&candidate) { + allowed += read; + } + } + for &reader in milp + .shared_readers + .get(&candidate) + .cloned() + .unwrap_or_default() + .iter() + { + let context = inputs.context_of_shareable[&reader]; + let read = milp.choice[context][&candidate]; + let (count, most) = reach_count(milp, &queries, reader); + let single = milp.builder.binary("g (single-query readers)"); + milp.builder + .row("9 reader constraints", constraint!(single <= read)); + let slack = (most - 1.0).max(0.0); + milp.builder.row( + "9 reader constraints", + constraint!(count <= 1.0 + slack * (1.0 - single)), + ); + allowed += single; + } + milp.builder + .row("9 reader constraints", constraint!(selected <= allowed)); + } + for &(context, used) in &uses { + match inputs.contexts[context].kind { + ContextKind::Query(_) => {} + ContextKind::Shared(m) => { + let (count, most) = reach_count(milp, &queries, m); + let slack = (most - 1.0).max(0.0); + milp.builder.row( + "9 reader constraints", + constraint!(count <= 1.0 + slack * (1.0 - used)), + ); + } + ContextKind::Ingestion => { + milp.note_inexact( + "highest shared materialization in I (forbidden)", + ); + milp.builder + .row("9 reader constraints", constraint!(used <= 0.0)); + } + } + } + } + ReaderConstraint::AllowedOutputs { outputs, .. } => { + for query in 0..query_count { + let references: Vec = (0..query_of_reference.len()) + .filter(|&reference| query_of_reference[reference] == query) + .collect(); + let disallowed = references + .iter() + .filter(|reference| !outputs.contains(reference)) + .count(); + if disallowed == 0 { + continue; + } + if disallowed < references.len() { + milp.note_inexact("AllowedOutputs over part of a query's outputs"); + } + forbid_reach(milp, query, candidate, &uses); + } + } + ReaderConstraint::IncompatibleOutputs { left, right, .. } => { + let (left, right) = (query_of_reference[*left], query_of_reference[*right]); + if left == right { + milp.note_inexact("IncompatibleOutputs within one query (forbidden)"); + forbid_reach(milp, left, candidate, &uses); + continue; + } + if shareable { + if let (Some(&l), Some(&r)) = ( + milp.reach.get(&(left, candidate)), + milp.reach.get(&(right, candidate)), + ) { + milp.builder + .row("9 reader constraints", constraint!(l + r <= 1.0)); + } + } + for &(context, used) in &uses { + if let ContextKind::Shared(m) = inputs.contexts[context].kind { + if let (Some(&l), Some(&r)) = + (milp.reach.get(&(left, m)), milp.reach.get(&(right, m))) + { + milp.builder + .row("9 reader constraints", constraint!(l + r + used <= 2.0)); + } + } + } + } + } + } + } +} + +/// Query `query` reaches no node of `candidate`. +fn forbid_reach( + milp: &mut SelectionMilp<'_>, + query: usize, + candidate: PhysicalCandidateId, + uses: &[(usize, Variable)], +) { + if let Some(&reach) = milp.reach.get(&(query, candidate)) { + milp.builder + .row("9 reader constraints", constraint!(reach <= 0.0)); + } else if let Some(&read) = milp.choice[query].get(&candidate) { + milp.builder + .row("9 reader constraints", constraint!(read <= 0.0)); + } + for &(context, used) in uses { + match milp.inputs.contexts[context].kind { + ContextKind::Query(q) if q == query => { + milp.builder + .row("9 reader constraints", constraint!(used <= 0.0)); + } + ContextKind::Query(_) => {} + ContextKind::Shared(m) => { + if let Some(&reach) = milp.reach.get(&(query, m)) { + milp.builder + .row("9 reader constraints", constraint!(reach + used <= 1.0)); + } + } + ContextKind::Ingestion => { + milp.note_inexact("a reader constraint on a candidate of I (forbidden)"); + milp.builder + .row("9 reader constraints", constraint!(used <= 0.0)); + } + } + } +} + +/// Query `query`'s latency bound: the smaller of its `max_latency_ms` and +/// its fixed repeat interval (`checks::latency_violation`). +pub(crate) fn latency_bound_ms(entry: &QueryWorkloadEntry) -> Option { + let interval = entry.recurrence.fixed_interval_ms().map(|ms| ms as f64); + match (entry.requirements.max_latency_ms, interval) { + (Some(bound), Some(interval)) => Some(bound.min(interval)), + (bound, interval) => bound.or(interval), + } +} + +/// The latency terms of context `context`'s computed runs (and of `m`'s +/// own run in its own context): `Σ d · indicator`, and the most it can be. +fn context_work(milp: &mut SelectionMilp<'_>, context: usize) -> (Expression, f64) { + let mut work = Expression::from(0.0); + let mut most = 0.0; + let mut runs: Vec = milp.choice[context] + .keys() + .copied() + .filter(|&c| milp.computes(context, c)) + .collect(); + if let ContextKind::Shared(own) = milp.inputs.contexts[context].kind { + runs.push(own); + } + // Per slot, only the slowest candidate can count (one per slot). + let mut most_per_slot: BTreeMap = BTreeMap::new(); + for candidate in runs { + let mut run_most = 0.0; + for term in milp.run_terms(context, candidate) { + if term.numbers.latency_ms > 0.0 { + let units = term.numbers.latency_ms / milp.ms_per_latency_unit; + work += units * term.indicator.clone(); + run_most = f64::max(run_most, units); + } + } + let slot = milp.slot(candidate); + let entry = most_per_slot.entry(slot).or_default(); + *entry = entry.max(run_most); + } + most += most_per_slot.values().sum::(); + (work, most) +} + +/// Row 7: each query's latency, `asap_cost::plan_cost`'s definition: its +/// request time, plus the work of every query-time run it reaches, each +/// once (its own context's, and each query-time shareable candidate's run +/// and context through `T[q, m]`), plus the longest compaction among the +/// ingestion-time states it reaches. +fn latency_rows(milp: &mut SelectionMilp<'_>) -> Result<(), SelectionMilpError> { + let inputs = milp.inputs; + let query_count = milp.query_count(); + let request_ms = milp.cost_inputs.measured_operation_costs.ns_per_evaluation * 1e-6; + // The latency unit: the most any query's latency can be (every + // candidate's work once, plus the request) is about 1,000 units. + let most_ms = request_ms + + inputs + .candidate_data + .iter() + .filter_map(|data| data.usage.as_ref().ok()) + .map(|usage| usage.own_latency_ms) + .sum::(); + if most_ms.is_finite() && most_ms > 0.0 { + milp.ms_per_latency_unit = most_ms / 1_000.0; + } + let unit = milp.ms_per_latency_unit; + let request = request_ms / unit; + // `L[m]` per query-time shareable candidate. + milp.builder.scope = Scope::SharedContexts; + let mut shared_latency: BTreeMap = BTreeMap::new(); + let shareables: Vec<(PhysicalCandidateId, usize)> = inputs + .context_of_shareable + .iter() + .map(|(&m, &context)| (m, context)) + .collect(); + for (m, context) in shareables { + if !milp.runs_at_query_time(context) { + continue; + } + let (work, most) = context_work(milp, context); + milp.builder.scope = Scope::SharedContexts; + if most <= 0.0 { + continue; + } + let latency = milp.builder.continuous("L (latency of m)", None); + milp.builder.row("7 latency", constraint!(latency >= work)); + shared_latency.insert(m, (latency, most)); + } + let compaction = cost_terms::compaction_ms(milp); + for query in 0..query_count { + let (mut work, mut most) = context_work(milp, query); + milp.builder.scope = Scope::Queries; + work += request; + most += request; + let mut compactions = Vec::new(); + let mut longest_compaction = 0.0f64; + for (&m, &(shared, shared_most)) in &shared_latency { + let Some(&reach) = milp.reach.get(&(query, m)) else { + continue; + }; + let through = milp.builder.continuous("T (latency of m in q)", None); + milp.builder.row( + "7 latency", + constraint!(through >= shared - shared_most * (1.0 - reach)), + ); + work += through; + most += shared_most; + } + for (&m, &ms) in &compaction { + if let Some(&reach) = milp.reach.get(&(query, m)) { + if ms > 0.0 { + compactions.push((ms / unit, reach)); + longest_compaction = longest_compaction.max(ms / unit); + } + } + } + if !compactions.is_empty() { + let longest = milp.builder.continuous("K (compaction)", None); + for (ms, reach) in compactions { + milp.builder + .row("7 latency", constraint!(longest >= ms * reach)); + } + work += longest; + most += longest_compaction; + } + let latency = milp.builder.continuous("L (latency of q)", None); + let bound = latency_bound_ms(&milp.entries[query]) + .map(|bound| bound / unit) + .filter(|&bound| bound < most); + // A binding bound's rows are divided by it, so HiGHS's absolute + // feasibility tolerance is relative to the bound. + let per_ms = bound.map_or(1.0, |bound| 1.0 / bound.max(1e-9)); + milp.builder + .row("7 latency", constraint!(per_ms * latency >= per_ms * work)); + if let Some(bound) = bound { + milp.builder + .row("7 latency", constraint!(per_ms * latency <= per_ms * bound)); + } + let bound = latency_bound_ms(&milp.entries[query]).map(|bound| bound / unit); + milp.latency.push(latency); + milp.latency_max + .push(bound.map_or(most, |bound| bound.min(most))); + } + milp.builder.scope = Scope::Global; + Ok(()) +} + +/// Row 8: accuracy (A1, revised 2026-10-31), per run `q` depends on: a run +/// unusable for `q` is never depended on, two whose pair is excluded are +/// not depended on together, and the shares depended on are at most each +/// such run's `parts`. +fn accuracy_rows(milp: &mut SelectionMilp<'_>) { + let inputs = milp.inputs; + milp.builder.scope = Scope::Global; + let approximate_runs: Vec<(usize, PhysicalCandidateId)> = milp + .runs() + .into_iter() + .filter(|&(_, candidate)| inputs.candidate_data[candidate.0].approximate) + .collect(); + for query in 0..milp.query_count() { + let exact_target = milp.entries[query].requirements.accuracy.is_exact(); + // (candidate, dep, parts) per run `q` may depend on. + let mut counted: Vec<(PhysicalCandidateId, Expression, usize)> = Vec::new(); + for &(context, candidate) in &approximate_runs { + let Some(weight) = inputs.accuracy_weights[query].get(&candidate).copied() else { + continue; + }; + let dep: Option = match inputs.contexts[context].kind { + ContextKind::Query(q) if q == query => { + milp.choice[context].get(&candidate).map(|&p| p.into()) + } + ContextKind::Query(_) => None, + ContextKind::Shared(own) if own == candidate => { + milp.reach.get(&(query, candidate)).map(|&a| a.into()) + } + ContextKind::Shared(own) => match milp.reach.get(&(query, own)) { + Some(&above) => { + let p = milp.choice[context][&candidate]; + Some( + milp.builder + .and_at_least( + "y (depends on a run in m)", + &[above.into(), p.into()], + ) + .into(), + ) + } + None => None, + }, + ContextKind::Ingestion => { + milp.note_inexact("an approximate candidate of I (forbidden)"); + let p = milp.choice[context][&candidate]; + milp.builder.row("8 accuracy", constraint!(p <= 0.0)); + None + } + }; + let Some(dep) = dep else { + continue; + }; + let usable = match weight { + _ if exact_target => false, + AccuracyWeight::One => true, + AccuracyWeight::Unusable(_) => false, + }; + if usable { + counted.push(( + candidate, + dep, + inputs.candidate_data[candidate.0].accuracy_budget_parts, + )); + } else { + milp.builder.row("8 accuracy", constraint!(dep <= 0.0)); + } + } + let mut pairs: Vec<(Expression, Expression)> = Vec::new(); + for &(first, second) in &inputs.accuracy_excluded_pairs[query] { + for (_, one, _) in counted.iter().filter(|(c, _, _)| *c == first) { + for (_, other, _) in counted.iter().filter(|(c, _, _)| *c == second) { + pairs.push((one.clone(), other.clone())); + } + } + } + for (one, other) in pairs { + milp.builder + .row("8 accuracy (pair)", constraint!(one + other <= 1.0)); + } + let most = counted.len() as f64; + if counted.iter().all(|&(_, _, parts)| parts as f64 >= most) { + continue; + } + let shares = milp.builder.continuous("N (approximate values)", None); + let sum: Expression = counted.iter().map(|(_, dep, _)| dep.clone()).sum(); + milp.builder.row("8 accuracy", constraint!(shares == sum)); + let mut by_parts: BTreeMap> = BTreeMap::new(); + for (_, dep, parts) in &counted { + if (*parts as f64) < most { + by_parts.entry(*parts).or_default().push(dep.clone()); + } + } + for (parts, deps) in by_parts { + let reads = milp + .builder + .continuous("A (depends at a parts value)", Some(1.0)); + for dep in deps { + milp.builder.row("8 accuracy", constraint!(reads >= dep)); + } + let slack = most - parts as f64; + milp.builder.row( + "8 accuracy", + constraint!(shares <= parts as f64 + slack * (1.0 - reads)), + ); + } + } +} + +/// Solves `milp` once with HiGHS, with the time left before `deadline` as +/// its time limit, from the warm start. +pub(crate) fn solve_selection_milp( + milp: SelectionMilp<'_>, + deadline: Option, + build_ms: u64, +) -> Result { + let size = milp.size(); + let warm_started = Instant::now(); + let search_budget = Instant::now() + + deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now()) / 5) + .unwrap_or(WARM_START_MOST_TIME) + .min(WARM_START_MOST_TIME); + let completion = super::completion::Completion::new(&milp); + let start = warm_start::warm_start(&milp, &completion, Some(search_budget)); + let warm_start_ms = warm_started.elapsed().as_millis() as u64; + let warm_start_cost_per_second = start.as_ref().map(|start| start.cost_per_second); + let scale = objective_scale( + &milp.objective_terms, + start.as_ref().map(|start| start.values.as_slice()), + ); + let objective: Expression = milp + .objective_terms + .iter() + .map(|&(variable, coefficient)| coefficient * scale * variable) + .sum(); + let SelectionMilp { + builder, + choice, + selected, + slot_links, + request_cost_per_second, + .. + } = milp; + let ModelBuilder { + variables, rows, .. + } = builder; + let column_of: HashMap = variables + .iter_variables_with_def() + .enumerate() + .map(|(column, (variable, _))| (variable, column)) + .collect(); + let problem = variables + .minimise(objective.clone()) + .using(highs) + .with_all(rows); + let mut model = problem + .try_into_inner() + .map_err(|error| SelectionMilpError::Solver(error.to_string()))?; + model.set_option("mip_rel_gap", 0.0); + model.set_option("mip_abs_gap", 0.0); + model.set_option("output_flag", false); + // HiGHS's MIP search is mostly serial; a thread per core costs + // gigabytes of address space on a large host (#1036 under 8 GB). + model.set_option("threads", HIGHS_THREADS); + let started_with_plan = start.is_some(); + if let Some(start) = start { + let mut columns = vec![0.0; column_of.len()]; + for (variable, value) in start.values { + columns[column_of[&variable]] = value; + } + model + .try_set_solution(Some(&columns), None, None, None) + .map_err(|status| SelectionMilpError::Solver(format!("warm start: {status:?}")))?; + } + let mut only_read_back = false; + if let Some(deadline) = deadline { + let seconds = deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(); + if seconds <= 0.0 { + if !started_with_plan { + return Err(SelectionMilpError::DeadlineReached); + } + only_read_back = true; + } + model.set_option("time_limit", seconds.max(MOMENT_TO_READ_THE_START_S)); + } + let started = Instant::now(); + let solved = model + .try_solve() + .map_err(|status| SelectionMilpError::Solver(format!("{status:?}")))?; + let solve_ms = started.elapsed().as_millis() as u64; + use highs::{HighsModelStatus, HighsSolutionStatus}; + let status = match solved.status() { + HighsModelStatus::Optimal if only_read_back => SelectionMilpStatus::TimeLimit, + HighsModelStatus::Optimal => SelectionMilpStatus::Optimal, + HighsModelStatus::Infeasible | HighsModelStatus::UnboundedOrInfeasible => { + return Err(SelectionMilpError::Infeasible) + } + HighsModelStatus::ReachedTimeLimit + | HighsModelStatus::ReachedInterrupt + | HighsModelStatus::ReachedIterationLimit + | HighsModelStatus::ReachedMemoryLimit + | HighsModelStatus::ReachedSolutionLimit => SelectionMilpStatus::TimeLimit, + other => return Err(SelectionMilpError::Solver(format!("{other:?}"))), + }; + if solved.primal_solution_status() != HighsSolutionStatus::Feasible { + return Err(SelectionMilpError::NoSolution { + status: format!("{:?}", solved.status()), + }); + } + let relative_gap = match status { + SelectionMilpStatus::Optimal => 0.0, + SelectionMilpStatus::TimeLimit => solved.mip_gap(), + }; + let dual_bound = solved + .double_info_value(c"mip_dual_bound") + .unwrap_or(f64::NAN); + let solution = solved.get_solution(); + let columns = solution.columns(); + // The objective of the integral plan: the incumbent's binaries, + // rounded, with every continuous variable at its cheapest value (the + // solver's own value carries its tolerances, relative to the scaled + // objective). At the deadline, HiGHS's incumbent objective (an upper + // bound) when the completion does not apply. + let objective_value = match completion.complete_columns(columns) { + Ok(completed) => completed.cost_per_second * scale, + Err(reason) => { + warm_start::debug(|| format!("completion of the incumbent failed: {reason}")); + solved.objective_value() + } + }; + warm_start::debug(|| { + format!( + "objective: HiGHS {:e}, completed {:e}", + solved.objective_value() / scale, + objective_value / scale + ) + }); + let on = |variable: Variable| columns[column_of[&variable]] > 0.5; + Ok(SelectionMilpSolution { + status, + cost_per_second: objective_value / scale, + request_cost_per_second, + cost_lower_bound_per_second: dual_bound / scale, + relative_gap, + chosen: choice + .iter() + .map(|options| { + options + .iter() + .filter(|(_, &variable)| on(variable)) + .map(|(&candidate, _)| candidate) + .collect() + }) + .collect(), + selected: selected + .iter() + .filter(|(_, &variable)| on(variable)) + .map(|(&candidate, _)| candidate) + .collect(), + slot_reads: slot_links + .iter() + .filter_map(|(&key, links)| { + links + .iter() + .find(|(_, link)| on(*link)) + .map(|&(slot, _)| (key, slot)) + }) + .collect(), + size, + build_ms, + warm_start_ms, + warm_start_cost_per_second, + solve_ms, + }) +} + +/// HiGHS's threads (its `threads` option). +const HIGHS_THREADS: i32 = 4; + +/// HiGHS's time limit when the warm start used up the time to the deadline. +const MOMENT_TO_READ_THE_START_S: f64 = 1e-3; + +/// The most time the warm start takes. +const WARM_START_MOST_TIME: std::time::Duration = std::time::Duration::from_secs(60); + +/// The factor the objective is multiplied by for HiGHS: the warm start's +/// cost becomes about 1,000, its largest coefficient at most 10⁷; without +/// a warm start cost, the largest coefficient becomes 1,000. +fn objective_scale(terms: &[(Variable, f64)], start: Option<&[(Variable, f64)]>) -> f64 { + let largest = terms + .iter() + .map(|(_, coefficient)| coefficient.abs()) + .fold(0.0, f64::max); + if !(largest > 0.0 && largest.is_finite()) { + return 1.0; + } + let started: HashMap = start.into_iter().flatten().copied().collect(); + let start_cost: f64 = terms + .iter() + .map(|(variable, coefficient)| coefficient * started.get(variable).copied().unwrap_or(0.0)) + .sum(); + if start_cost > 0.0 && start_cost.is_finite() { + (1_000.0 / start_cost).min(1e7 / largest) + } else { + 1_000.0 / largest + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs new file mode 100644 index 000000000..9b5980295 --- /dev/null +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -0,0 +1,961 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use super::*; +use crate::groups::{prepare_milp_inputs, AccuracyWeight}; +use crate::milp::decode::{decode, ChosenCandidates}; +use asap_cost::{node_map, plan_cost, PlanCost, ResourceUnitCost}; +use asap_types::ir::properties::ExecutionTiming; +use asap_types::ir::{OperatorNode, QueryRoot}; +use asap_types::physical::{Materialization, Medium}; +use asap_types::stages::{AssignmentRealizer, DagOfSelectedCandidates, PhysicalCandidate}; +use asap_types::workload::{DataWorkload, DurationMs, RangeEvaluation}; + +// --------------------------------------------------------------------------- +// Through Stages 1 and 2 +// --------------------------------------------------------------------------- + +/// A workload through the real Stages 1 and 2, with the roots the +/// realizer rebuilds plans from. +pub(crate) struct Fixture { + pub(crate) physical: PhysicalASAPMemoCandidates, + pub(crate) roots: Vec, + pub(crate) entries: Vec, + pub(crate) data: DataWorkload, + pub(crate) query_time_results_retained: bool, +} + +/// One query of a fixture: PromQL over metric `m`, its repeat interval in +/// ms, its relative error target (`None`: exact), its latency bound and +/// its range evaluation. +#[derive(Clone, Copy)] +pub(crate) struct Query<'a> { + promql: &'a str, + interval_ms: u32, + max_error: Option, + max_latency_ms: Option, + range_evaluation: Option, +} + +pub(crate) fn query(promql: &str, interval_ms: u32) -> Query<'_> { + Query { + promql, + interval_ms, + max_error: None, + max_latency_ms: None, + range_evaluation: None, + } +} + +pub(crate) fn approximate(promql: &str, interval_ms: u32, max_error: f64) -> Query<'_> { + Query { + max_error: Some(max_error), + ..query(promql, interval_ms) + } +} + +/// `queries` through Stage 1 and Stage 2 as the facade runs them, over the +/// complete pane-width domain, metric `m` with 100 series sampled every +/// second. +pub(crate) fn through_stages(queries: &[Query<'_>], raw_data_retained: bool) -> Fixture { + through_stages_with(queries, raw_data_retained, false) +} + +/// [`through_stages`] over metric `m` declared non-negative (so relative +/// errors compose through quotients, and Pass 1 keeps DDSketch). +pub(crate) fn through_stages_non_negative(queries: &[Query<'_>]) -> Fixture { + through_stages_over(queries, true, false, true) +} + +/// [`through_stages`], with rows arriving (100 per second) when +/// `ingesting`, so Stage 2 offers ingestion-time candidates. +pub(crate) fn through_stages_with( + queries: &[Query<'_>], + raw_data_retained: bool, + ingesting: bool, +) -> Fixture { + through_stages_over(queries, raw_data_retained, ingesting, false) +} + +/// The fixture builder behind [`through_stages`]. +fn through_stages_over( + queries: &[Query<'_>], + raw_data_retained: bool, + ingesting: bool, + values_non_negative: bool, +) -> Fixture { + use asap_logical_asap_optimization::pass1::logical_memo_candidates::generate_per_computation_logical_memo_candidates; + use asap_logical_asap_optimization::pass2::logical_memo_candidates::{ + generate_logical_asap_memo_candidates, Pass2RoundState, + }; + use asap_logical_asap_optimization::pass2::memo::shared_configurations::PaneWidthDomain; + use asap_logical_asap_optimization::pass2::memo::DagOfLogicalMemoGroups; + use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_types::ir::properties::AccuracyTarget; + use asap_types::workload::{ + Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, + }; + let target = |query: &Query<'_>| match query.max_error { + Some(error) => AccuracyTarget::max_error(error).failure_probability(0.01), + None => AccuracyTarget::exact(), + }; + let roots: Vec = queries + .iter() + .map(|query| QueryRoot::Operator(lower_promql(query.promql, target(query)))) + .collect(); + let entries: Vec = queries + .iter() + .map(|query| { + let mut entry = entry_with( + target(query), + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( + query.interval_ms, + ))), + ); + entry.predictability = Predictability::Predictable { known_at: None }; + entry.requirements.max_latency_ms = query.max_latency_ms; + entry.range_evaluation = query.range_evaluation; + entry + }) + .collect(); + let mut data = DataWorkload::sampled_every(["m".to_string()], DurationMs(1_000)); + data.sources.get_mut("m").expect("m").series_count = Some(100); + data.sources.get_mut("m").expect("m").values_non_negative = values_non_negative; + if ingesting { + data.arrival = asap_types::workload::DataArrival::Ingesting { + rows_per_second: 100.0, + }; + } + let targets: Vec = queries.iter().map(target).collect(); + let pass1 = generate_per_computation_logical_memo_candidates( + DagOfLogicalMemoGroups::from_roots(&roots), + &targets, + &asap_accuracy::BuiltinAccuracyModel, + &asap_accuracy::NoAccuracyEvidence, + &data, + None, + None, + ) + .unwrap(); + let mut round = Pass2RoundState::new(pass1); + let logical = generate_logical_asap_memo_candidates( + &mut round, + &entries, + PaneWidthDomain::Complete, + None, + ) + .unwrap(); + let physical = asap_physical_asap_optimization::generate_physical_asap_memo_candidates( + &logical, + &entries, + &data, + false, + raw_data_retained, + None, + ) + .unwrap(); + Fixture { + physical, + roots, + entries, + data, + query_time_results_retained: false, + } +} + +/// What one checked solve gives. +pub(crate) struct Checked { + pub(crate) solution: SelectionMilpSolution, + pub(crate) plan: PlanCost, + pub(crate) dag: DagOfSelectedCandidates, +} + +/// `fixture`'s MILP under `cost_inputs`, with `restrict` adding rows first: +/// solved, decoded, realized by Stage 2 and costed by +/// `asap_cost::plan_cost`. At `Optimal` the objective plus the request fees +/// must equal `plan_cost`'s cost; at the deadline be at least it. +pub(crate) fn solve_and_check( + fixture: &Fixture, + cost_inputs: &CostInputs, + restrict: impl FnOnce(&mut SelectionMilp<'_>), +) -> Result { + solve_and_check_by(fixture, cost_inputs, restrict, None) +} + +/// [`solve_and_check`] with HiGHS's time limit `limit`. +fn solve_and_check_by( + fixture: &Fixture, + cost_inputs: &CostInputs, + restrict: impl FnOnce(&mut SelectionMilp<'_>), + limit: Option, +) -> Result { + let inputs = prepare_milp_inputs( + &fixture.physical, + &fixture.entries, + &fixture.data, + cost_inputs, + ); + let mut milp = build_selection_milp( + &fixture.physical, + &inputs, + &fixture.entries, + cost_inputs, + SelectionMilpOptions { + enforce_accuracy: true, + }, + )?; + assert!(milp.inexact.is_empty(), "inexact terms: {:?}", milp.inexact); + restrict(&mut milp); + let solution = solve_selection_milp(milp, limit.map(|limit| Instant::now() + limit), 0)?; + let dag = decode( + &fixture.physical, + &inputs, + &ChosenCandidates { + chosen: solution.chosen.clone(), + selected: solution.selected.clone(), + slot_reads: solution.slot_reads.clone(), + }, + ) + .expect("the solution decodes"); + dag.validate(&fixture.physical) + .expect("the decoded selection is valid"); + let realizer = asap_physical_asap_optimization::Stage2AssignmentRealizer { + original_roots: &fixture.roots, + entries: &fixture.entries, + data: &fixture.data, + query_time_results_retained: fixture.query_time_results_retained, + }; + let realized = realizer + .realize(&fixture.physical, &dag) + .expect("the realizer accepts the decoded plan"); + let plan_dag = &realized.plan.dag; + let recurrence: Vec<_> = fixture + .entries + .iter() + .map(|entry| entry.recurrence.clone()) + .collect(); + let ranges: Vec<_> = fixture + .entries + .iter() + .map(|entry| entry.range_evaluation) + .collect(); + let (plan, _) = plan_cost( + plan_dag, + &node_map(plan_dag), + &asap_cost::kept_pane_chains(plan_dag), + &recurrence, + &ranges, + &fixture.data, + cost_inputs, + cost_inputs.frozen_calibration_factor.unwrap_or(1.0), + ) + .expect("plan_cost costs the plan"); + let expected = plan.cost_per_second.expect("cost model 1") - solution.request_cost_per_second; + let tolerance = 1e-6 * expected.abs().max(1e-12); + match solution.status { + SelectionMilpStatus::Optimal => assert!( + (solution.cost_per_second - expected).abs() <= tolerance, + "MILP objective {:e} != plan_cost {:e} (without {:e} of request fees); per node: {:#?}", + solution.cost_per_second, + expected, + solution.request_cost_per_second, + plan.per_node + ), + SelectionMilpStatus::TimeLimit => assert!( + expected <= solution.cost_per_second + tolerance, + "plan_cost {expected:e} above the incumbent's objective {:e}", + solution.cost_per_second + ), + } + Ok(Checked { + solution, + plan, + dag, + }) +} + +pub(crate) fn cost_inputs() -> CostInputs { + CostInputs { + frozen_calibration_factor: Some(1.0), + ..CostInputs::default() + } +} + +/// The `aws-fargate-us-east-1` cost model of the memo examples. +fn aws_fargate_cost_inputs() -> CostInputs { + let defaults = cost_inputs(); + CostInputs { + cost_model: asap_cost::CostModel::UsageOverTime(ResourceUnitCost::aws_fargate_us_east_1( + defaults.resource_speed, + )), + ..defaults + } +} + +/// The candidates of `milp` that satisfy `wanted`. +fn candidates_where( + milp: &SelectionMilp<'_>, + wanted: impl Fn(&PhysicalCandidate, &OperatorNode) -> bool, +) -> Vec { + (0..milp.physical.candidates.len()) + .map(PhysicalCandidateId) + .filter(|&candidate| { + wanted( + &milp.physical.candidates[candidate.0], + milp.physical.physical_node(candidate), + ) + }) + .collect() +} + +/// Adds `Σ x[m] ≥ 1` over the shareable `candidates`: one is selected. +fn require_one_of(milp: &mut SelectionMilp<'_>, candidates: &[PhysicalCandidateId]) { + let sum: Expression = candidates + .iter() + .filter_map(|candidate| milp.selected.get(candidate).copied()) + .map(Expression::from) + .sum(); + milp.builder.row("test", constraint!(sum >= 1.0)); +} + +/// Adds `x[m] = 0` and `p[ctx, c] = 0` everywhere for `candidates`. +fn forbid(milp: &mut SelectionMilp<'_>, candidates: &[PhysicalCandidateId]) { + for candidate in candidates { + if let Some(&selected) = milp.selected.get(candidate) { + milp.builder.row("test", constraint!(selected <= 0.0)); + } + for context in 0..milp.choice.len() { + if let Some(&choice) = milp.choice[context].get(candidate) { + milp.builder.row("test", constraint!(choice <= 0.0)); + } + } + } +} + +/// The candidates that are pane chains or run at ingestion time. +fn pane_chains_and_ingestion(milp: &SelectionMilp<'_>) -> Vec { + candidates_where(milp, |candidate, node| { + candidate.timing == ExecutionTiming::IngestionTime + || matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) + }) +} + +/// How many nodes of `dag` run `candidate`. +fn runs_of(dag: &DagOfSelectedCandidates, candidate: PhysicalCandidateId) -> usize { + dag.candidate_of_node + .iter() + .filter(|&&c| c == candidate) + .count() +} + +/// `sum(m)` repeating every minute and `sum(m) + max(m)` every 30 s: the +/// second query does more work, so its evaluations take longer. +pub(crate) fn shared_sum() -> Fixture { + through_stages( + &[query("sum(m)", 60_000), query("sum(m) + max(m)", 30_000)], + true, + ) +} + +/// `sum_over_time(m[5m])` and `sum_over_time(m[1h])` over raw data. +pub(crate) fn two_windows() -> Fixture { + through_stages( + &[ + query("sum_over_time(m[5m])", 60_000), + query("sum_over_time(m[1h])", 60_000), + ], + true, + ) +} + +// --------------------------------------------------------------------------- +// Cost terms equal `plan_cost` (§3) +// --------------------------------------------------------------------------- + +// The optimum's objective equals `plan_cost` of the plan it decodes to. +#[test] +fn the_optimum_costs_what_plan_cost_says() { + for costs in [cost_inputs(), aws_fargate_cost_inputs()] { + let checked = solve_and_check(&shared_sum(), &costs, |_| {}).unwrap(); + assert_eq!(checked.solution.status, SelectionMilpStatus::Optimal); + } +} + +// Held memory and schedule groups: a `MatAtQuery(Memory)` result both +// queries read (every 60 s and every 30 s) is held until the slower reader +// finishes, once per execution at the schedule groups' rates. +#[test] +fn held_memory_of_a_result_readers_of_different_latency_share() { + let fixture = shared_sum(); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let shared: Vec = candidates_where(milp, |candidate, _| { + candidate.materialization == Materialization::MatAtQuery(Medium::Memory) + }) + .into_iter() + .filter(|&candidate| milp.queries_reaching(candidate).len() == 2) + .collect(); + assert!(!shared.is_empty()); + require_one_of(milp, &shared); + }) + .unwrap(); + let latencies: Vec = checked + .plan + .usage + .latency_per_query + .iter() + .map(|latency| latency.total_ms()) + .collect(); + assert!(latencies[0] != latencies[1], "{latencies:?}"); + assert!(checked + .plan + .per_node + .values() + .any(|node| node.detail.contains("holds"))); +} + +// Raw retention: with one scan per window, the source keeps the longer +// window's raw data once. +#[test] +fn raw_retention_keeps_the_longest_window() { + let checked = solve_and_check(&two_windows(), &cost_inputs(), |milp| { + let excluded = pane_chains_and_ingestion(milp); + forbid(milp, &excluded); + }) + .unwrap(); + assert!(checked + .plan + .per_node + .values() + .any(|node| node.detail.contains("of raw data on disk"))); +} + +// A scan read through two offsets in one query evaluation: the scan run +// reads the union of the two windows, and the MILP charges exactly that +// union (`δ` over the window kinds read), as `plan_cost` does. +#[test] +fn a_scan_read_through_two_offsets_is_costed_at_their_union() { + let fixture = through_stages( + &[query( + "sum_over_time(m[5m]) + sum_over_time(m[5m] offset 1h)", + 60_000, + )], + true, + ); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let excluded = pane_chains_and_ingestion(milp); + forbid(milp, &excluded); + }) + .unwrap(); + assert!( + checked + .solution + .size + .families + .contains_key("δ (scan window sets)"), + "{:?}", + checked.solution.size.families.keys().collect::>() + ); +} + +// A kept pane chain whose readers have different range-evaluation spans: +// it keeps the panes of the longest span, charged by retention levels. +#[test] +fn a_kept_pane_chain_keeps_the_longest_readers_span() { + let span = |span_ms| { + Some(RangeEvaluation { + step_ms: 60_000, + span_ms, + }) + }; + let fixture = through_stages_with( + &[ + Query { + range_evaluation: span(600_000), + ..query("sum_over_time(m[1h])", 60_000) + }, + Query { + range_evaluation: span(3_600_000), + ..query("sum_over_time(m[1h])", 60_000) + }, + ], + false, + true, + ); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let chains = candidates_where(milp, |candidate, node| { + matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) + && matches!( + candidate.materialization, + Materialization::MatAtIngestion(_) + ) + }); + require_one_of(milp, &chains); + }) + .unwrap(); + assert!( + checked + .solution + .size + .families + .contains_key("π (pane retention levels)"), + "{:?}", + checked.solution.size.families.keys().collect::>() + ); +} + +// A `NotMaterialized` input of a `MatAtQuery` candidate both queries read +// (`rate(m[5m])` under `sum` and `max`, one schedule group) runs once, in +// the candidate's context, and is costed as `plan_cost` costs it. +#[test] +fn a_shared_results_not_materialized_input_runs_once() { + let fixture = through_stages( + &[ + query("sum(rate(m[5m]))", 60_000), + query("max(rate(m[5m]))", 60_000), + ], + true, + ); + let mut shared = Vec::new(); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + shared = candidates_where(milp, |candidate, _| { + matches!(candidate.materialization, Materialization::MatAtQuery(_)) + }) + .into_iter() + .filter(|&m| milp.queries_reaching(m).len() == 2) + .filter(|&m| { + let context = milp.context_of(m).expect("a context"); + milp.choice[context] + .keys() + .any(|&c| milp.computes(context, c)) + }) + .collect(); + assert!(!shared.is_empty(), "a shared result over a computed input"); + require_one_of(milp, &shared); + }) + .unwrap(); + let m = *checked + .solution + .selected + .iter() + .find(|m| shared.contains(m)) + .expect("selected"); + assert_eq!(runs_of(&checked.dag, m), 1); + let node = checked + .dag + .candidate_of_node + .iter() + .position(|&c| c == m) + .expect("a node"); + for edge in checked.dag.edges.iter().filter(|e| e.consumer.0 == node) { + let input = checked.dag.candidate_of_node[edge.producer.0]; + let readers = checked + .dag + .edges + .iter() + .filter(|e| e.producer == edge.producer) + .count(); + assert_eq!(readers, 1, "m's input {input:?} is read by m alone"); + } +} + +// One ingestion scan read by sketches of two configurations (two quantile +// targets) runs once in `I` and is costed once. +#[test] +fn one_ingestion_scan_shared_by_two_sketch_configurations_is_charged_once() { + let fixture = through_stages_with( + &[ + approximate("quantile_over_time(0.5, m[5m])", 60_000, 0.01), + approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.05), + ], + true, + true, + ); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let sketches = candidates_where(milp, |candidate, node| { + candidate.timing == ExecutionTiming::IngestionTime + && candidate.materialization != Materialization::NotMaterialized + && matches!( + node.asap(), + Some(asap_types::ir::ASAPOp::SummaryAgg { .. }) + | Some(asap_types::ir::ASAPOp::PaneChain { .. }) + ) + }); + let sum: Expression = sketches + .iter() + .filter_map(|c| milp.selected.get(c).copied()) + .map(Expression::from) + .sum(); + milp.builder.row("test", constraint!(sum >= 2.0)); + // Every query-time scan is excluded: both read ingestion state. + let scans = candidates_where(milp, |candidate, node| { + candidate.timing == ExecutionTiming::QueryTime && node.operator.kind_name() == "Scan" + }); + forbid(milp, &scans); + }) + .unwrap(); + let ingestion_scans: Vec = (0..checked.dag.candidate_of_node.len()) + .filter(|&node| { + let candidate = checked.dag.candidate_of_node[node]; + fixture.physical.candidates[candidate.0].timing == ExecutionTiming::IngestionTime + && fixture + .physical + .physical_node(candidate) + .operator + .kind_name() + == "Scan" + }) + .collect(); + assert_eq!(ingestion_scans.len(), 1, "{:?}", checked.dag); +} + +// A stateless subtree read by two state nodes of one query evaluation +// (`avg` reads a sum and a count accumulator over one `q50/q50 + q90` +// subtree) is computed once: no candidate runs twice in the plan. +#[test] +fn a_stateless_subtree_two_state_nodes_read_runs_once() { + let fixture = through_stages( + &[query( + "avg by (job) (quantile_over_time(0.5, m[5m]) / quantile_over_time(0.5, m[5m]) \ + + quantile_over_time(0.9, m[5m]))", + 60_000, + )], + true, + ); + let checked = solve_and_check(&fixture, &cost_inputs(), |_| {}).unwrap(); + let mut runs: BTreeMap = BTreeMap::new(); + for &candidate in &checked.dag.candidate_of_node { + *runs.entry(candidate).or_default() += 1; + } + assert!(runs.values().all(|&n| n == 1), "{runs:?}"); +} + +// --------------------------------------------------------------------------- +// Structure +// --------------------------------------------------------------------------- + +// "Used only if read": a query context may not select a shareable +// candidate no used reader there reads, so a phantom read cannot satisfy +// `MinimumQueryReaders`. With every reader of a two-reader `MatAtQuery` +// result in the second query's context forbidden, selecting it is +// infeasible. +#[test] +fn a_phantom_reader_does_not_satisfy_minimum_query_readers() { + let fixture = shared_sum(); + let costs = cost_inputs(); + let inputs = prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); + let options = SelectionMilpOptions { + enforce_accuracy: true, + }; + let mut milp = build_selection_milp( + &fixture.physical, + &inputs, + &fixture.entries, + &costs, + options, + ) + .unwrap(); + let constrained: Vec = fixture + .physical + .reader_constraints + .iter() + .filter_map(|constraint| match constraint { + ReaderConstraint::MinimumQueryReaders { + candidate, minimum, .. + } if *minimum >= 2 => Some(*candidate), + _ => None, + }) + .filter(|&m| milp.selected.contains_key(&m) && milp.choice[1].contains_key(&m)) + .collect(); + let m = *constrained.first().expect("a two-reader shareable result"); + let selected = milp.selected[&m]; + milp.builder.row("test", constraint!(selected >= 1.0)); + let readers: Vec = milp.readers[1][&m] + .iter() + .filter_map(|reader| match reader { + Reader::Candidate(reader, _) => Some(*reader), + _ => None, + }) + .collect(); + assert!( + !milp.readers[1][&m] + .iter() + .any(|reader| matches!(reader, Reader::Output(_))), + "the result is not an output of the second query" + ); + for reader in readers { + let choice = milp.choice[1][&reader]; + milp.builder.row("test", constraint!(choice <= 0.0)); + } + assert_eq!( + solve_selection_milp(milp, None, 0).err(), + Some(SelectionMilpError::Infeasible) + ); +} + +// A `NotMaterialized` query-time candidate selected in two contexts is two +// nodes; a shareable candidate two queries read is one node. +#[test] +fn runs_decode_one_node_per_context_and_one_per_shareable_candidate() { + let fixture = through_stages( + &[query("sum(m)", 60_000), query("sum(m) * 2", 60_000)], + true, + ); + let unshared = solve_and_check(&fixture, &cost_inputs(), |milp| { + let shared = candidates_where(milp, |candidate, _| { + candidate.timing == ExecutionTiming::QueryTime + && candidate.materialization != Materialization::NotMaterialized + }); + forbid(milp, &shared); + let ingestion = candidates_where(milp, |candidate, _| { + candidate.timing == ExecutionTiming::IngestionTime + }); + forbid(milp, &ingestion); + }) + .unwrap(); + let mut runs: BTreeMap = BTreeMap::new(); + for &candidate in &unshared.dag.candidate_of_node { + *runs.entry(candidate).or_default() += 1; + } + assert!(runs.values().any(|&n| n == 2), "{runs:?}"); + let mut shared_result = Vec::new(); + let shared = solve_and_check(&fixture, &cost_inputs(), |milp| { + shared_result = candidates_where(milp, |candidate, _| { + candidate.materialization == Materialization::MatAtQuery(Medium::Memory) + }) + .into_iter() + .filter(|&m| milp.queries_reaching(m).len() == 2) + .collect(); + require_one_of(milp, &shared_result); + }) + .unwrap(); + let m = *shared + .solution + .selected + .iter() + .find(|m| shared_result.contains(m)) + .expect("selected"); + assert_eq!(runs_of(&shared.dag, m), 1); +} + +// A query over its latency bound: with the bound below the fastest plan's +// latency there is no plan; at the optimum's latency, the optimum stays. +#[test] +fn a_plan_over_a_latency_bound_is_infeasible() { + let fixture = shared_sum(); + let optimum = solve_and_check(&fixture, &cost_inputs(), |_| {}).unwrap(); + let latency = optimum.plan.usage.latency_per_query[1].total_ms(); + let mut bounded = shared_sum(); + bounded.entries[1].requirements.max_latency_ms = Some(latency * (1.0 + 1e-9)); + let at_bound = solve_and_check(&bounded, &cost_inputs(), |_| {}).unwrap(); + assert!(at_bound.plan.usage.latency_per_query[1].total_ms() <= latency * (1.0 + 1e-9)); + let mut tight = shared_sum(); + tight.entries[1].requirements.max_latency_ms = Some(1e-9); + assert!(matches!( + solve_and_check(&tight, &cost_inputs(), |_| {}).err(), + Some(SelectionMilpError::Infeasible) + )); +} + +// Cost models 2 and 3 are not encoded. +#[test] +fn cost_models_2_and_3_are_unsupported() { + use asap_cost::{ClusterLimits, ClusterObjective, CostModel}; + let fixture = shared_sum(); + for model in [ + CostModel::ProvisionedCapacity(ResourceUnitCost::illustrative()), + CostModel::ClusterCapacity(ClusterLimits { + memory_bytes: u64::MAX, + disk_bytes: u64::MAX, + cpu_cores: None, + objective: ClusterObjective::LowestLatency, + }), + ] { + let costs = CostInputs { + cost_model: model, + ..cost_inputs() + }; + let inputs = + prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); + let options = SelectionMilpOptions { + enforce_accuracy: true, + }; + assert!(matches!( + build_selection_milp( + &fixture.physical, + &inputs, + &fixture.entries, + &costs, + options + ) + .err(), + Some(SelectionMilpError::UnsupportedCostModel(_)) + )); + } +} + +// Accuracy rows on `q0.5 / q0.9` over metric `m` declared non-negative, +// weights from the composition: a candidate unusable for the query (a +// DDSketch denominator sized for the whole target) is never depended on, +// and two at `parts = 2` are depended on together unless their pair is +// excluded. (Pass 1 already prunes KLL, whose rank error composes no +// guarantee through the quotient.) +#[test] +fn accuracy_rows_follow_the_composition_on_a_ratio() { + let fixture = through_stages_non_negative(&[approximate( + "quantile_over_time(0.5, m[5m]) / quantile_over_time(0.9, m[5m])", + 60_000, + 0.01, + )]); + let costs = cost_inputs(); + let inputs = prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); + let of = |wanted: &dyn Fn(&AccuracyWeight, usize) -> bool| -> Vec { + inputs.accuracy_weights[0] + .iter() + .filter(|(class, weight)| { + wanted(weight, inputs.candidate_data[class.0].accuracy_budget_parts) + }) + .map(|(class, _)| *class) + .collect() + }; + let unusable = of(&|weight, _| matches!(weight, AccuracyWeight::Unusable(_))); + let usable_at_two = of(&|weight, parts| *weight == AccuracyWeight::One && parts == 2); + assert!(!unusable.is_empty() && usable_at_two.len() >= 2); + let solve = |inputs: &MilpInputs, restrict: &dyn Fn(&mut SelectionMilp<'_>)| { + let mut milp = build_selection_milp( + &fixture.physical, + inputs, + &fixture.entries, + &costs, + SelectionMilpOptions { + enforce_accuracy: true, + }, + ) + .unwrap(); + restrict(&mut milp); + solve_selection_milp(milp, None, 0).map(|_| ()) + }; + // Each candidate's runs: `x[m]`, or `p` in a context computing it. + let runs_of = |milp: &SelectionMilp<'_>, candidates: &[PhysicalCandidateId]| -> Expression { + let mut sum = Expression::from(0.0); + for &candidate in candidates { + if let Some(&selected) = milp.selected.get(&candidate) { + sum += selected; + } + for context in 0..milp.choice.len() { + if milp.computes(context, candidate) { + if let Some(&choice) = milp.choice[context].get(&candidate) { + sum += choice; + } + } + } + } + sum + }; + assert_eq!( + solve(&inputs, &|milp| { + let sum = runs_of(milp, &unusable); + milp.builder.row("test", constraint!(sum >= 1.0)); + }) + .err(), + Some(SelectionMilpError::Infeasible) + ); + let two_of = |milp: &mut SelectionMilp<'_>| { + let sum = runs_of(milp, &usable_at_two); + milp.builder.row("test", constraint!(sum >= 2.0)); + }; + assert!(solve(&inputs, &two_of).is_ok()); + let mut excluded = inputs.clone(); + for (index, &first) in usable_at_two.iter().enumerate() { + for &second in &usable_at_two[index + 1..] { + excluded.accuracy_excluded_pairs[0].push((first, second)); + } + } + assert_eq!( + solve(&excluded, &two_of).err(), + Some(SelectionMilpError::Infeasible) + ); +} + +// The warm start is a feasible plan whose completion's objective equals +// what `plan_cost` says of it: fixing every binary to it, the solve +// returns the warm start's cost. +#[test] +fn the_warm_start_is_a_feasible_plan_costed_exactly() { + for fixture in [shared_sum(), two_windows()] { + let mut start_cost = None; + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let completion = crate::milp::completion::Completion::new(milp); + let start = + crate::milp::warm_start::warm_start(milp, &completion, None).expect("a warm start"); + start_cost = Some(start.cost_per_second); + let values: HashMap = start.values.into_iter().collect(); + let mut fixed = Vec::new(); + for choices in &milp.choice { + fixed.extend(choices.values().copied()); + } + fixed.extend(milp.selected.values().copied()); + for variable in fixed { + let value = values.get(&variable).copied().unwrap_or(0.0); + milp.builder.row("test", constraint!(variable == value)); + } + }) + .unwrap(); + let start_cost = start_cost.expect("costed"); + let tolerance = 1e-6 * start_cost.abs().max(1e-12); + assert!( + (checked.solution.cost_per_second - start_cost).abs() <= tolerance, + "warm start {start_cost:e} != plan_cost {:e}", + checked.solution.cost_per_second + ); + } +} + +// A deadline yields a plan: HiGHS starts from the warm start, so even no +// time ends with a validated incumbent, reported as the deadline. +#[test] +fn a_deadline_yields_the_warm_starts_plan() { + let checked = + solve_and_check_by(&two_windows(), &cost_inputs(), |_| {}, Some(Duration::ZERO)).unwrap(); + assert_eq!(checked.solution.status, SelectionMilpStatus::TimeLimit); + let start = checked + .solution + .warm_start_cost_per_second + .expect("the warm start was costed"); + assert!(checked.solution.cost_per_second <= start * (1.0 + 1e-9)); +} + +// RangeHeavy templates 7 and 4: the widened scan puts a `Project` in the +// memo group of the scan it reads. The group's roots are split by +// self-read depth, so the plan is acyclic, valid, accepted by the realizer +// and costed exactly. +#[test] +fn a_widened_scan_reading_its_own_memo_group_plans_exactly() { + let fixture = through_stages( + &[ + approximate("sum by (job) (sum_over_time(m[1h]))", 60_000, 0.05), + approximate("sum_over_time(m[1h])", 60_000, 0.05), + ], + true, + ); + let reads_own_group = (0..fixture.physical.candidates.len()).any(|candidate| { + let group = fixture + .physical + .memo_group_of(PhysicalCandidateId(candidate)); + fixture.physical.candidates[candidate] + .producer_candidates_per_input + .iter() + .any(|producers| { + producers + .iter() + .any(|p| p.0 != candidate && fixture.physical.memo_group_of(*p) == group) + }) + }); + assert!( + reads_own_group, + "the fixture has a root reading its own group" + ); + for costs in [cost_inputs(), aws_fargate_cost_inputs()] { + let checked = solve_and_check(&fixture, &costs, |_| {}).unwrap(); + assert_eq!(checked.solution.status, SelectionMilpStatus::Optimal); + } +} diff --git a/crates/plan-selection/src/milp/warm_start.rs b/crates/plan-selection/src/milp/warm_start.rs new file mode 100644 index 000000000..beb5e59e3 --- /dev/null +++ b/crates/plan-selection/src/milp/warm_start.rs @@ -0,0 +1,780 @@ +//! A feasible starting plan for the group selection MILP (#1095 §2.0, +//! "Solving strategy" step 1), so HiGHS starts with an incumbent and a +//! deadline yields a plan. +//! +//! **Each query's cheapest plan.** Queries are planned one after another +//! into one plan. A query's plan is derived from its output references +//! down: in each context, each slot an input needs takes the candidate +//! with the cheapest estimated subtree (its usage at the context's rate +//! plus its inputs' cheapest subtrees), unless the context already chose a +//! candidate of that slot, which every reader must then accept (one +//! candidate per slot per context); a dead end backtracks to the next +//! candidate. A shareable candidate's context is derived once, when it is +//! first selected; a candidate of `I` is chosen in `I`. +//! +//! **Repairs per query.** If the query's plan depends on more approximate +//! values than one of them is sized for, on a pair excluded together, or +//! on a candidate unusable for it, the query is planned again with exact +//! candidates only; if its estimated latency is over its bound, it is +//! planned again by latency alone. +//! +//! **Greedy sharing.** A second plan prices what the queries planned +//! before already selected (a shareable candidate, a candidate of `I`) at +//! zero, so later queries read it. Both plans, in workload order and +//! reversed, are costed exactly by the MILP's own rows and objective +//! ([`Completion`]); the cheapest feasible one is the start. +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::time::Instant; + +use asap_cost::execution_demand::evaluation_rate; +use asap_types::stages::{output_references, PhysicalCandidateId, ReaderConstraint}; +use good_lp::Variable; + +use crate::groups::{AccuracyWeight, ContextKind, ResultSlot, RunKind}; + +use super::completion::Completion; +use super::cost_terms::NodeNumbers; +use super::model::{latency_bound_ms, SelectionMilp}; + +/// The most derivation steps one query's plan takes before giving up. +const MOST_STEPS_PER_QUERY: usize = 200_000; + +/// The starting plan. +pub(crate) struct StartingPlan { + /// Every variable's value. + pub(crate) values: Vec<(Variable, f64)>, + /// Its objective (cost per second without the request fees). + pub(crate) cost_per_second: f64, +} + +/// What a derivation minimizes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Objective { + Cost, + Latency, +} + +/// The candidates a query's derivation may use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Approximate { + /// Approximate candidates usable for the query. + Usable, + /// Those sized for at least this many shares. + SizedFor(usize), + /// Exact candidates only. + None, +} + +/// A plan under construction: per context, the candidate of each slot, and +/// the selected shareable candidates. +#[derive(Debug, Clone, Default)] +struct Plan { + chosen: Vec>, + selected: BTreeSet, + /// Per multi-slot input `(context, reader, input)`, the slot it reads. + slot_reads: BTreeMap<(usize, PhysicalCandidateId, usize), ResultSlot>, +} + +/// One change to a [`Plan`], to undo. +enum Undo { + Chose(usize, ResultSlot), + Selected(PhysicalCandidateId), + SlotRead(usize, PhysicalCandidateId, usize), +} + +/// A feasible plan (module documentation), or `None` when none was found +/// before `budget`. +pub(crate) fn warm_start( + milp: &SelectionMilp<'_>, + completion: &Completion, + budget: Option, +) -> Option { + let estimates = Estimates::new(milp); + let mut best: Option = None; + let count = milp.query_count(); + let mut orders: Vec> = vec![(0..count).collect(), (0..count).rev().collect()]; + // More orders, while the budget lasts: a fixed pseudo-random sequence + // of permutations, so runs are reproducible. + let mut state: u64 = 0x9e37_79b9_7f4a_7c15; + for _ in 0..MORE_ORDERS { + let mut order: Vec = (0..count).collect(); + for at in (1..count).rev() { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + order.swap(at, (state % (at as u64 + 1)) as usize); + } + orders.push(order); + } + // (share, amortized): alone; reading what earlier queries selected; and + // that with each shareable candidate's own cost divided among the + // queries that may reach it. + let strategies = [(false, false), (true, false), (true, true)]; + let mut tried = 0; + 'orders: for order in &orders { + for &(share, amortized) in &strategies { + if budget.is_some_and(|budget| Instant::now() >= budget) && best.is_some() { + break 'orders; + } + tried += 1; + let Some(plan) = + sequential_plan(milp, &estimates, order, share, amortized, &BTreeSet::new()) + else { + debug(|| format!("warm start: no plan (share {share}, amortized {amortized})")); + continue; + }; + let binaries = binaries_of(milp, &plan); + let completed = completion.complete(&binaries); + if let Err(reason) = &completed { + debug(|| format!("warm start: completion failed (share {share}): {reason}")); + } + if let Ok(completed) = completed { + debug(|| { + format!( + "warm start: plan (share {share}, amortized {amortized}) costs {:e}, {} shareable selected", + completed.cost_per_second, + plan.selected.len() + ) + }); + if best + .as_ref() + .is_none_or(|known| completed.cost_per_second < known.cost_per_second) + { + best = Some(StartingPlan { + values: completion.values(&completed), + cost_per_second: completed.cost_per_second, + }); + } + } + } + } + // Greedy sharing: prefer one more shareable candidate that several + // queries may read (most readers first), kept when the exact cost + // falls. + let mut sharable: Vec<(usize, PhysicalCandidateId)> = milp + .inputs + .context_of_shareable + .keys() + .map(|&m| (estimates.readers[m.0], m)) + .filter(|&(readers, _)| readers >= 2) + .collect(); + sharable.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + let mut preferred: BTreeSet = BTreeSet::new(); + for (_, m) in sharable { + if best.is_none() || budget.is_some_and(|budget| Instant::now() >= budget) { + break; + } + let mut trial = preferred.clone(); + trial.insert(m); + tried += 1; + let Some(plan) = sequential_plan(milp, &estimates, &orders[0], true, false, &trial) else { + continue; + }; + if let Ok(completed) = completion.complete(&binaries_of(milp, &plan)) { + if best + .as_ref() + .is_some_and(|known| completed.cost_per_second < known.cost_per_second) + { + preferred = trial; + best = Some(StartingPlan { + values: completion.values(&completed), + cost_per_second: completed.cost_per_second, + }); + } + } + } + debug(|| { + format!( + "warm start: {tried} plans tried, best {:?}", + best.as_ref().map(|best| best.cost_per_second) + ) + }); + best +} + +/// The pseudo-random orders tried after workload order and its reverse, +/// within the budget. +const MORE_ORDERS: usize = 30; + +/// The plan's binaries: `p[ctx, c]` and `x[m]` set to 1. +fn binaries_of(milp: &SelectionMilp<'_>, plan: &Plan) -> Vec<(Variable, f64)> { + let mut binaries = Vec::new(); + for (context, chosen) in plan.chosen.iter().enumerate() { + for candidate in chosen.values() { + if let Some(&variable) = milp.choice[context].get(candidate) { + binaries.push((variable, 1.0)); + } + } + } + for shareable in &plan.selected { + if let Some(&variable) = milp.selected.get(shareable) { + binaries.push((variable, 1.0)); + } + } + for (key, slot) in &plan.slot_reads { + if let Some(links) = milp.slot_links.get(key) { + for &(at, link) in links { + if at == *slot { + binaries.push((link, 1.0)); + } + } + } + } + binaries +} + +/// Every query planned in `order` into one plan; with `share`, what is +/// already selected costs nothing to read. +fn sequential_plan( + milp: &SelectionMilp<'_>, + estimates: &Estimates, + order: &[usize], + share: bool, + amortized: bool, + preferred: &BTreeSet, +) -> Option { + let mut plan = Plan { + chosen: vec![BTreeMap::new(); milp.choice.len()], + selected: BTreeSet::new(), + slot_reads: BTreeMap::new(), + }; + for &query in order { + let attempts = [ + (Objective::Cost, Approximate::Usable), + (Objective::Cost, Approximate::SizedFor(2)), + (Objective::Cost, Approximate::SizedFor(4)), + (Objective::Cost, Approximate::None), + (Objective::Latency, Approximate::Usable), + (Objective::Latency, Approximate::SizedFor(2)), + (Objective::Latency, Approximate::SizedFor(4)), + (Objective::Latency, Approximate::None), + ]; + // The first attempt meeting accuracy and the latency estimate; else + // the last meeting accuracy (the latency estimate is from each + // candidate's first costed usage, so it may be pessimistic: the + // completion checks the exact latency). + let mut planned = false; + let mut fallback = None; + for (attempt, (objective, approximate)) in attempts.into_iter().enumerate() { + let mut deriving = Derivation { + milp, + estimates, + query, + objective, + approximate, + share, + amortized, + preferred, + undo: Vec::new(), + steps: 0, + references: output_references(&milp.physical.query_roots) + .into_iter() + .enumerate() + .filter(|(_, (q, _))| *q == query) + .map(|(reference, _)| reference) + .collect(), + }; + let derived = deriving.derive_query(&mut plan).is_ok(); + let accurate = derived && meets_accuracy(milp, &plan, query); + let fast = accurate && meets_latency(milp, &plan, query); + debug(|| { + format!( + "warm start: q{query} {objective:?}/{approximate:?}: derived {derived}, \ + accuracy {accurate}, latency {fast}, steps {}", + deriving.steps + ) + }); + if fast { + planned = true; + break; + } + if accurate { + fallback = Some(attempt); + } + let undo = std::mem::take(&mut deriving.undo); + undo_all(&mut plan, undo); + } + if let (false, Some(attempt)) = (planned, fallback) { + let (objective, approximate) = attempts[attempt]; + let mut deriving = Derivation { + milp, + estimates, + query, + objective, + approximate, + share, + amortized, + preferred, + undo: Vec::new(), + steps: 0, + references: output_references(&milp.physical.query_roots) + .into_iter() + .enumerate() + .filter(|(_, (q, _))| *q == query) + .map(|(reference, _)| reference) + .collect(), + }; + planned = deriving.derive_query(&mut plan).is_ok(); + } + if !planned { + return None; + } + } + Some(plan) +} + +fn undo_all(plan: &mut Plan, undo: Vec) { + for change in undo.into_iter().rev() { + match change { + Undo::Chose(context, slot) => { + plan.chosen[context].remove(&slot); + } + Undo::Selected(shareable) => { + plan.selected.remove(&shareable); + } + Undo::SlotRead(context, reader, input) => { + plan.slot_reads.remove(&(context, reader, input)); + } + } + } +} + +/// Per context and candidate, the estimated cost and latency of the +/// cheapest subtree, per query (the rate of a query context; shared +/// contexts at the slowest rate of the queries that may reach them). +struct Estimates { + /// Per context, its rate (executions per second). + rate: Vec, + /// Per candidate, its numbers (first costed context). + numbers: Vec>, + /// Per candidate, the queries that may depend on it (shareable ones). + readers: Vec, +} + +impl Estimates { + fn new(milp: &SelectionMilp<'_>) -> Self { + let horizon = milp.cost_inputs.non_repeating_query_horizon_seconds; + let query_rate: Vec = milp + .entries + .iter() + .map(|entry| { + evaluation_rate([(&entry.recurrence, entry.range_evaluation)], horizon) + .unwrap_or(1.0) + }) + .collect(); + let rate = milp + .inputs + .contexts + .iter() + .map(|context| match context.kind { + ContextKind::Query(query) => query_rate[query], + ContextKind::Ingestion => 1.0, + ContextKind::Shared(m) if context.at_query_time => { + let readers = milp.queries_reaching(m); + readers + .iter() + .map(|&query| query_rate[query]) + .fold(f64::INFINITY, f64::min) + .min(1e9) + } + ContextKind::Shared(_) => 1.0, + }) + .collect(); + let numbers = milp + .inputs + .candidate_data + .iter() + .map(|data| data.usage.as_ref().ok().map(NodeNumbers::of)) + .collect(); + let mut readers = vec![0; milp.inputs.candidate_data.len()]; + for &m in milp.inputs.context_of_shareable.keys() { + readers[m.0] = milp.queries_reaching(m).len(); + } + Self { + rate, + numbers, + readers, + } + } +} + +/// One query's derivation into a plan. +struct Derivation<'m, 'a> { + milp: &'m SelectionMilp<'a>, + estimates: &'m Estimates, + query: usize, + objective: Objective, + approximate: Approximate, + share: bool, + /// Divide a shareable candidate's own cost among the queries that may + /// reach it. + amortized: bool, + /// Shareable candidates estimated free to read (greedy sharing). + preferred: &'m BTreeSet, + undo: Vec, + steps: usize, + /// The query's output references. + references: Vec, +} + +impl Derivation<'_, '_> { + /// Whether the query may use `candidate`. + fn allowed(&self, candidate: PhysicalCandidateId) -> bool { + let inputs = self.milp.inputs; + if inputs.candidate_data[candidate.0].approximate { + if self.approximate == Approximate::None + || self.milp.entries[self.query] + .requirements + .accuracy + .is_exact() + { + return false; + } + if let Some(AccuracyWeight::Unusable(_)) = + inputs.accuracy_weights[self.query].get(&candidate) + { + return false; + } + if let Approximate::SizedFor(shares) = self.approximate { + if inputs.candidate_data[candidate.0].accuracy_budget_parts < shares { + return false; + } + } + } + // A candidate needing two or more reading queries is not planned + // alone; one some of the query's outputs may not reach is not used. + let references = &self.references; + !self + .milp + .physical + .reader_constraints + .iter() + .any(|constraint| match constraint { + ReaderConstraint::MinimumQueryReaders { + candidate: c, + minimum, + .. + } => *c == candidate && *minimum > 1, + ReaderConstraint::AllowedOutputs { + candidate: c, + outputs, + } => *c == candidate && references.iter().any(|r| !outputs.contains(r)), + ReaderConstraint::IncompatibleOutputs { + candidate: c, + left, + right, + } => *c == candidate && references.contains(left) && references.contains(right), + ReaderConstraint::HighestSharedQueryMaterialization { .. } => false, + }) + } + + /// The estimated cost (or latency) of `candidate`'s own run read in + /// `context`, plus the cheapest subtrees of its inputs. + fn estimate( + &self, + plan: &Plan, + memo: &mut HashMap<(usize, PhysicalCandidateId), f64>, + context: usize, + candidate: PhysicalCandidateId, + ) -> f64 { + if let Some(&known) = memo.get(&(context, candidate)) { + return known; + } + memo.insert((context, candidate), f64::INFINITY); + let milp = self.milp; + let inputs = milp.inputs; + let value = (|| { + if !self.allowed(candidate) { + return f64::INFINITY; + } + let Some(numbers) = self.estimates.numbers[candidate.0] else { + return f64::INFINITY; + }; + let kind = inputs.kind[candidate.0]; + // Where its own run is and where it reads its inputs. + let (run_context, already) = if milp.computes(context, candidate) { + (context, false) + } else { + match kind { + RunKind::Shareable => match milp.context_of(candidate) { + Some(own) => (own, plan.selected.contains(&candidate)), + None => return f64::INFINITY, + }, + RunKind::Ingestion => { + let ingestion = milp.ingestion_context(); + if !milp.choice[ingestion].contains_key(&candidate) { + return f64::INFINITY; + } + let chosen = plan.chosen[ingestion].get(&inputs.slot[candidate.0]); + (ingestion, chosen == Some(&candidate)) + } + RunKind::PerContext => return f64::INFINITY, + } + }; + if (self.share && already) || self.preferred.contains(&candidate) { + return 0.0; + } + let mut own = match self.objective { + Objective::Cost => { + numbers.execution_cost * self.estimates.rate[run_context] + + numbers.standing_cost + } + Objective::Latency => numbers.latency_ms, + }; + if self.amortized && kind == RunKind::Shareable && self.objective == Objective::Cost { + own /= self.estimates.readers[candidate.0].max(1) as f64; + } + let mut total = own; + for producers in &inputs.producer_candidates_per_input[candidate.0] { + let best = producers + .iter() + .filter(|producer| milp.choice[run_context].contains_key(producer)) + .filter(|producer| { + kind == RunKind::Shareable + || inputs.slot[producer.0] != inputs.slot[candidate.0] + }) + .map(|&producer| self.estimate(plan, memo, run_context, producer)) + .fold(f64::INFINITY, f64::min); + total += best; + } + total + })(); + memo.insert((context, candidate), value); + value + } + + fn derive_query(&mut self, plan: &mut Plan) -> Result<(), ()> { + let milp = self.milp; + let mut memo = HashMap::new(); + for (reference, (query, _)) in output_references(&milp.physical.query_roots) + .into_iter() + .enumerate() + { + if query != self.query { + continue; + } + let allowed: Vec = milp.inputs.output_candidates[reference] + .iter() + .copied() + .filter(|candidate| milp.choice[query].contains_key(candidate)) + .collect(); + self.read(plan, &mut memo, query, &allowed)?; + } + Ok(()) + } + + /// Makes `context` give one of `allowed` (one input's producers, or an + /// output's candidates). + fn read( + &mut self, + plan: &mut Plan, + memo: &mut HashMap<(usize, PhysicalCandidateId), f64>, + context: usize, + allowed: &[PhysicalCandidateId], + ) -> Result { + self.steps += 1; + if self.steps > MOST_STEPS_PER_QUERY { + return Err(()); + } + let milp = self.milp; + let inputs = milp.inputs; + // A slot already chosen here with an allowed candidate. + for &candidate in allowed { + if plan.chosen[context].get(&inputs.slot[candidate.0]) == Some(&candidate) { + return Ok(candidate); + } + } + let mut options: Vec<(f64, PhysicalCandidateId)> = allowed + .iter() + .copied() + .filter(|candidate| milp.choice[context].contains_key(candidate)) + .filter(|candidate| !plan.chosen[context].contains_key(&inputs.slot[candidate.0])) + .map(|candidate| (self.estimate(plan, memo, context, candidate), candidate)) + .collect(); + options.retain(|(estimate, _)| estimate.is_finite()); + options.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + for (_, candidate) in options { + let mark = self.undo.len(); + if self.choose(plan, memo, context, candidate).is_ok() { + return Ok(candidate); + } + let undo = self.undo.split_off(mark); + undo_all(plan, undo); + } + Err(()) + } + + /// Chooses `candidate` for its slot in `context` and derives what it + /// needs. + fn choose( + &mut self, + plan: &mut Plan, + memo: &mut HashMap<(usize, PhysicalCandidateId), f64>, + context: usize, + candidate: PhysicalCandidateId, + ) -> Result<(), ()> { + let milp = self.milp; + let inputs = milp.inputs; + let slot = inputs.slot[candidate.0]; + plan.chosen[context].insert(slot, candidate); + self.undo.push(Undo::Chose(context, slot)); + if milp.computes(context, candidate) { + for (input, producers) in inputs.producer_candidates_per_input[candidate.0] + .iter() + .enumerate() + { + let producers: Vec = producers + .iter() + .copied() + .filter(|producer| inputs.slot[producer.0] != slot) + .collect(); + let read = self.read(plan, memo, context, &producers)?; + if milp.slot_links.contains_key(&(context, candidate, input)) { + plan.slot_reads + .insert((context, candidate, input), inputs.slot[read.0]); + self.undo.push(Undo::SlotRead(context, candidate, input)); + } + } + return Ok(()); + } + match inputs.kind[candidate.0] { + RunKind::Shareable => { + if plan.selected.contains(&candidate) { + return Ok(()); + } + plan.selected.insert(candidate); + self.undo.push(Undo::Selected(candidate)); + let own = milp.context_of(candidate).ok_or(())?; + for producers in &inputs.producer_candidates_per_input[candidate.0] { + self.read(plan, memo, own, producers)?; + } + Ok(()) + } + RunKind::Ingestion => { + let ingestion = milp.ingestion_context(); + if context == ingestion { + return Err(()); + } + self.read(plan, memo, ingestion, &[candidate]).map(|_| ()) + } + RunKind::PerContext => Err(()), + } + } +} + +/// The runs query `query` depends on in `plan`: `(context, candidate)` for +/// candidates computed in its context and in the contexts of the +/// shareable candidates it reaches, and each such shareable candidate +/// `(its context, itself)`. +fn runs_reached( + milp: &SelectionMilp<'_>, + plan: &Plan, + query: usize, +) -> BTreeSet<(usize, PhysicalCandidateId)> { + let inputs = milp.inputs; + let mut reached = BTreeSet::new(); + let mut stack: Vec<(usize, PhysicalCandidateId)> = Vec::new(); + for (reference, (q, _)) in output_references(&milp.physical.query_roots) + .into_iter() + .enumerate() + { + if q != query { + continue; + } + for &candidate in &inputs.output_candidates[reference] { + if plan.chosen[query].get(&inputs.slot[candidate.0]) == Some(&candidate) { + stack.push((query, candidate)); + } + } + } + while let Some((context, candidate)) = stack.pop() { + let (run_context, own) = if milp.computes(context, candidate) { + (context, false) + } else { + match inputs.kind[candidate.0] { + RunKind::Shareable => match milp.context_of(candidate) { + Some(own) => (own, true), + None => continue, + }, + RunKind::Ingestion => (milp.ingestion_context(), false), + RunKind::PerContext => continue, + } + }; + if !reached.insert((run_context, candidate)) { + continue; + } + let slot = inputs.slot[candidate.0]; + for producers in &inputs.producer_candidates_per_input[candidate.0] { + for &producer in producers { + if !own && inputs.slot[producer.0] == slot { + continue; + } + if plan.chosen[run_context].get(&inputs.slot[producer.0]) == Some(&producer) { + stack.push((run_context, producer)); + break; + } + } + } + } + reached +} + +/// Whether query `query`'s approximate runs in `plan` meet the A1 rows. +fn meets_accuracy(milp: &SelectionMilp<'_>, plan: &Plan, query: usize) -> bool { + let inputs = milp.inputs; + let runs: Vec = runs_reached(milp, plan, query) + .into_iter() + .map(|(_, candidate)| candidate) + .filter(|candidate| inputs.candidate_data[candidate.0].approximate) + .filter(|candidate| inputs.accuracy_weights[query].contains_key(candidate)) + .collect(); + if runs.is_empty() { + return true; + } + if milp.entries[query].requirements.accuracy.is_exact() { + return false; + } + let count = runs.len(); + for &candidate in &runs { + if matches!( + inputs.accuracy_weights[query].get(&candidate), + Some(AccuracyWeight::Unusable(_)) + ) { + return false; + } + if inputs.candidate_data[candidate.0].accuracy_budget_parts < count { + return false; + } + } + !inputs.accuracy_excluded_pairs[query] + .iter() + .any(|(first, second)| runs.contains(first) && runs.contains(second)) +} + +/// Whether query `query`'s estimated latency in `plan` is within its bound. +fn meets_latency(milp: &SelectionMilp<'_>, plan: &Plan, query: usize) -> bool { + let Some(bound) = latency_bound_ms(&milp.entries[query]) else { + return true; + }; + let request_ms = milp.cost_inputs.measured_operation_costs.ns_per_evaluation * 1e-6; + let latency: f64 = runs_reached(milp, plan, query) + .into_iter() + .filter(|&(context, _)| milp.runs_at_query_time(context)) + .filter_map(|(_, candidate)| milp.inputs.candidate_data[candidate.0].usage.as_ref().ok()) + .map(|usage| usage.own_latency_ms) + .sum(); + debug(|| { + format!( + "warm start: q{query} estimated latency {} ms, bound {bound} ms", + latency + request_ms + ) + }); + latency + request_ms <= bound +} + +/// Prints `message` to stderr when `ASAP_STAGE3_DEBUG` is set. +pub(crate) fn debug(message: impl FnOnce() -> String) { + if std::env::var_os("ASAP_STAGE3_DEBUG").is_some() { + eprintln!("{}", message()); + } +} diff --git a/crates/plan-selection/src/report.rs b/crates/plan-selection/src/report.rs index fbd7668c9..b06ec740a 100644 --- a/crates/plan-selection/src/report.rs +++ b/crates/plan-selection/src/report.rs @@ -86,6 +86,10 @@ pub enum RejectionCause { /// It ties with the selected candidate and comes later in enumeration /// order. TiedLostOnEnumerationOrder, + /// No plan meets every query's accuracy target, latency bound and the + /// catalog's reader constraints together: the selection MILP is + /// infeasible. The reason names a query when it has no plan even alone. + Infeasible, } impl RejectionCause { @@ -101,6 +105,7 @@ impl RejectionCause { | Self::LatencyBoundExceeded | Self::LatencyOverRepeatInterval | Self::OverClusterCapacity + | Self::Infeasible ) } } @@ -234,6 +239,25 @@ pub struct SearchReport { pub model_construction_ms: u64, pub solver_ms: u64, pub validation_ms: u64, + /// Set when `asap_cost::plan_cost` of the selected plan differs from + /// the selection MILP's objective and the model encoded some cost terms + /// inexactly (open questions on #1095, e.g. the working-memory term), so + /// the plan is returned with its cost equality unproven. `None` when + /// the two are equal (within a relative 10⁻⁶) or the old search ran. A + /// difference with no inexact term is an error + /// ([`ErrorSelectingPlan::ObjectiveDiffersFromPlanCost`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inexact_objective: Option, +} + +/// The selected plan's cost per second as the selection MILP's objective +/// and as `asap_cost::plan_cost` computes it (both without request fees), +/// and the cost terms the model encoded inexactly, with how many times. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InexactObjective { + pub objective_per_second: f64, + pub plan_cost_per_second: f64, + pub inexact_terms: BTreeMap, } impl SearchReport { @@ -300,6 +324,29 @@ pub enum ErrorSelectingPlan { /// The memo search's MILP solver failed. #[error("MILP solver: {0}")] Solver(String), + /// The selection MILP implements cost model 1 only (#1095 §3). + #[error("the selection MILP does not support {0}")] + UnsupportedCostModel(String), + /// `asap_cost::plan_cost` of the selected plan differs from the + /// selection MILP's objective (both without request fees) and the model + /// encoded every cost term exactly: a bug in a cost term. + #[error( + "the selection MILP's objective {objective_per_second:e} differs from plan_cost \ + {plan_cost_per_second:e} (per second, without request fees)" + )] + ObjectiveDiffersFromPlanCost { + objective_per_second: f64, + plan_cost_per_second: f64, + }, + /// The selected plan fails a check the selection MILP encodes + /// (accuracy through the budget shares, latency): a bug in the + /// accuracy weights, the sizing rule or a latency term, never a reason + /// to re-solve. + #[error("the selected plan fails a check the MILP encodes ({cause:?}): {reason}")] + SelectedPlanFailedCheck { + cause: RejectionCause, + reason: String, + }, } #[cfg(test)] diff --git a/crates/plan-selection/src/search/select.rs b/crates/plan-selection/src/search/select.rs index 671be2f42..09f08e481 100644 --- a/crates/plan-selection/src/search/select.rs +++ b/crates/plan-selection/src/search/select.rs @@ -3,16 +3,15 @@ //! refinement loop (cost models 1 and 2 with a frozen calibration) or the //! bounded objective traversal (cost model 3, or a measured cost table //! without a frozen factor). #1095 replaces the engine; this interface stays. +//! With `ASAP_STAGE3_SEARCH=milp` the new search runs instead +//! ([`crate::select`]), until #1095 C6 makes it the default. use std::time::{Duration, Instant}; use asap_cost::{CostInputs, CostModel}; -use asap_types::ir::{ASAPOp, Operator}; use asap_types::stages::{ - AssignmentRealizer, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, - PhysicalCandidateError, SelectedNodeId, + AssignmentRealizer, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, SelectedNodeId, }; -use asap_types::workload::bounds::confirmed_weights; use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; use crate::models::{AccuracyCheckInputs, SelectionModels}; @@ -56,6 +55,18 @@ pub fn select_plan( cost_inputs .validate() .map_err(ErrorSelectingPlan::InvalidSettings)?; + // #1095 C5: the new search, behind its internal switch until C6. + if crate::select::milp_search_enabled() { + return crate::select::select_plan_with_milp( + physical, + entries, + data, + accuracy_checks, + cost_inputs, + realizer, + deadline, + ); + } let models = SelectionModels::new(accuracy_checks, cost_inputs); let calibration_policy = CandidateCalibration::for_models(models).map_err(ErrorSelectingPlan::Solver)?; @@ -185,6 +196,7 @@ pub fn select_plan( model_construction_ms: ms(outcome.metrics.model_construction_seconds), solver_ms: ms(outcome.metrics.solver_seconds), validation_ms: ms(validation_seconds), + inexact_objective: None, }, }) } @@ -199,34 +211,14 @@ struct Outcome { lower_bound: Option, } -/// The selected nodes whose summary (`SummaryAgg` or `SummaryTumblingWindowChain`) reads -/// update weights `data` proves non-negative although the candidate leaves -/// the requirement open: the selected plan realized once more, each node -/// checked. +/// The selected nodes whose open count-min weight requirement `data` +/// proves: the selected plan realized once more, each node checked. fn proven_non_negative_weights( physical: &PhysicalASAPMemoCandidates, selected: &DagOfSelectedCandidates, realizer: &dyn AssignmentRealizer, data: &DataWorkload, ) -> Result, ErrorSelectingPlan> { - let realized = realizer - .realize(physical, selected) - .map_err(|error| match error { - PhysicalCandidateError::RejectedAssignment(reason) => { - ErrorSelectingPlan::RealizerRejected(reason) - } - error => ErrorSelectingPlan::RealizerRejected(error.to_string()), - })?; - Ok(realized - .operator_node_of_selected_node - .iter() - .enumerate() - .filter(|(_, node)| match &node.operator { - Operator::ASAP( - ASAPOp::SummaryAgg { input, .. } | ASAPOp::SummaryTumblingWindowChain { input, .. }, - ) => confirmed_weights(&input.weight_domain, data) != input.weight_domain, - _ => false, - }) - .map(|(node, _)| SelectedNodeId(node)) - .collect()) + let realized = crate::select::realize(physical, selected, realizer)?; + Ok(crate::select::proven_non_negative_weights(&realized, data)) } diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs new file mode 100644 index 000000000..966be802d --- /dev/null +++ b/crates/plan-selection/src/select.rs @@ -0,0 +1,348 @@ +//! #1095 Steps 3–6: the group selection MILP behind [`select_plan`](crate::select_plan), +//! on while the internal switch [`STAGE3_SEARCH_ENV`] is `milp` (the old +//! engine stays the default until C6; there is no fallback between them). +//! +//! One MILP solve and one realization, no loop: +//! 1. the MILP's inputs (candidate dominance, execution contexts, accuracy +//! weights) and the model, solved once with HiGHS from a warm start, +//! the deadline as its time limit; +//! 2. the solution decoded into a [`DagOfSelectedCandidates`] +//! ([`crate::milp::decode`]) and validated against the catalog; +//! 3. the plan realized once through the injected realizer: a rejection is +//! a Stage 2 catalog bug ([`ErrorSelectingPlan::RealizerRejected`]); +//! 4. exact checks: `asap_cost::plan_cost` of the realized plan minus the +//! request fees equals the objective within a relative 10⁻⁶ at +//! `Optimal`, and is at most the objective at the deadline (otherwise an +//! error, or, when the model encoded some term inexactly, +//! [`SearchReport::inexact_objective`]); every query's accuracy +//! (`satisfies`) and latency hold, or it is an error. +//! +//! With `ASAP_STAGE3_REPORT=`, one JSON line per run is appended +//! there: the model's size per family and per scope (the shareable +//! candidates' contexts apart), the timings, the warm start's cost, the +//! objective, `plan_cost` and the dual bound (measurement only). + +use std::collections::BTreeMap; +use std::time::Instant; + +use asap_cost::{kept_pane_chains, node_map, plan_cost, CostInputs}; +use asap_types::ir::{ASAPOp, Operator}; +use asap_types::stages::{ + AssignmentRealizer, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, + PhysicalCandidateError, PhysicalCandidateId, PruneReason, PrunedCandidate, RealizedPlan, + SelectedNodeId, +}; +use asap_types::workload::bounds::confirmed_weights; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +use crate::checks::{accuracy_check, accuracy_targets, latency_violation}; +use crate::groups::{prepare_milp_inputs_with, MilpInputs}; +use crate::milp::decode::{decode, ChosenCandidates}; +use crate::milp::model::{ + build_selection_milp, solve_selection_milp, SelectionMilpError, SelectionMilpOptions, + SelectionMilpStatus, +}; +use crate::models::{AccuracyCheckInputs, SelectionModels}; +use crate::report::{ + ErrorSelectingPlan, InexactObjective, Rejection, RejectionCause, SearchReport, SearchStatus, + Selection, +}; + +/// The environment variable that switches Stage 3 to the new search: +/// `ASAP_STAGE3_SEARCH=milp`. Any other value, or none, keeps the old +/// engine (until #1095 C6 makes the new search the default). +pub(crate) const STAGE3_SEARCH_ENV: &str = "ASAP_STAGE3_SEARCH"; + +/// Whether [`STAGE3_SEARCH_ENV`] turns the new search on. +pub(crate) fn milp_search_enabled() -> bool { + std::env::var(STAGE3_SEARCH_ENV).is_ok_and(|value| value == "milp") +} + +/// The largest relative difference between the objective and `plan_cost` +/// that counts as equal. +const COST_EQUALITY_TOLERANCE: f64 = 1e-6; + +/// [`select_plan`](crate::select_plan) with the new search (module +/// documentation). `cost_inputs` is already validated. +pub(crate) fn select_plan_with_milp( + physical: &PhysicalASAPMemoCandidates, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + accuracy_checks: &AccuracyCheckInputs<'_>, + cost_inputs: &CostInputs, + realizer: &dyn AssignmentRealizer, + deadline: Option, +) -> Result { + // The MILP's coefficients and `plan_cost` must use one calibration: a + // measured cost table needs a frozen factor. + if cost_inputs.frozen_calibration_factor.is_none() + && !cost_inputs.measured_operation_costs.operators.is_empty() + { + return Err(ErrorSelectingPlan::UnsupportedCostModel( + "a measured operation cost table without a frozen calibration factor".into(), + )); + } + let formula_scale = cost_inputs.frozen_calibration_factor.unwrap_or(1.0); + let models = SelectionModels::new(accuracy_checks, cost_inputs); + // The A1 rows decide accuracy only when the built-in rule does: a model + // or evidence that may accept an output otherwise is asked after the + // solve. + let enforce_accuracy = !(accuracy_checks.model.may_accept_regardless_of_guarantee() + || accuracy_checks.evidence.may_provide_evidence()); + + let construction_started = Instant::now(); + let inputs = prepare_milp_inputs_with(physical, entries, data, &models); + let pruned_candidates = pruned_before_the_milp(physical, &inputs); + let milp = build_selection_milp( + physical, + &inputs, + entries, + cost_inputs, + SelectionMilpOptions { enforce_accuracy }, + ) + .map_err(selection_error)?; + let build_ms = construction_started.elapsed().as_millis() as u64; + let solution = solve_selection_milp(milp, deadline, build_ms).map_err(selection_error)?; + + let validation_started = Instant::now(); + let chosen = ChosenCandidates { + chosen: solution.chosen.clone(), + selected: solution.selected.clone(), + slot_reads: solution.slot_reads.clone(), + }; + let selected_dag = decode(physical, &inputs, &chosen).map_err(|reason| { + ErrorSelectingPlan::Solver(format!("the selection does not decode: {reason}")) + })?; + selected_dag.validate(physical).map_err(|reason| { + ErrorSelectingPlan::Solver(format!("the decoded selection is invalid: {reason}")) + })?; + let realized = realize(physical, &selected_dag, realizer)?; + let plan = &realized.plan; + let recurrence: Vec<_> = entries.iter().map(|e| e.recurrence.clone()).collect(); + let ranges: Vec<_> = entries.iter().map(|e| e.range_evaluation).collect(); + let (cost, _) = plan_cost( + &plan.dag, + &node_map(&plan.dag), + &kept_pane_chains(&plan.dag), + &recurrence, + &ranges, + data, + cost_inputs, + formula_scale, + ) + .map_err(|(node, error)| ErrorSelectingPlan::Cost { + candidate: plan.id.clone(), + node, + error, + })?; + let plan_cost_per_second = cost.cost_per_second.ok_or_else(|| { + ErrorSelectingPlan::UnsupportedCostModel("a cost model without a cost per second".into()) + })? - solution.request_cost_per_second; + let objective_per_second = solution.cost_per_second; + let difference = (objective_per_second - plan_cost_per_second).abs(); + let tolerance = COST_EQUALITY_TOLERANCE * plan_cost_per_second.abs().max(f64::MIN_POSITIVE); + // At the deadline the incumbent's auxiliary variables may sit above + // their true values: the objective is an upper bound on `plan_cost`. + let within = match solution.status { + SelectionMilpStatus::Optimal => difference <= tolerance, + SelectionMilpStatus::TimeLimit => plan_cost_per_second <= objective_per_second + tolerance, + }; + report_run(&solution, objective_per_second, plan_cost_per_second); + let inexact_objective = if within { + None + } else if solution.size.inexact.is_empty() { + return Err(ErrorSelectingPlan::ObjectiveDiffersFromPlanCost { + objective_per_second, + plan_cost_per_second, + }); + } else { + Some(InexactObjective { + objective_per_second, + plan_cost_per_second, + inexact_terms: solution + .size + .inexact + .iter() + .map(|(reason, count)| (reason.to_string(), *count)) + .collect(), + }) + }; + let accepted_accuracy_per_query = + accuracy_check(plan, &accuracy_targets(entries), data, &models).map_err(|failure| { + ErrorSelectingPlan::SelectedPlanFailedCheck { + cause: failure.cause, + reason: failure.reason, + } + })?; + if let Some((cause, reason)) = latency_violation(&cost, entries) { + return Err(ErrorSelectingPlan::SelectedPlanFailedCheck { cause, reason }); + } + let nodes_with_proven_non_negative_weights = proven_non_negative_weights(&realized, data); + let validation_ms = validation_started.elapsed().as_millis() as u64; + Ok(Selection { + selected_dag, + nodes_with_proven_non_negative_weights, + cost, + accepted_accuracy_per_query, + pruned_candidates, + search_report: SearchReport { + status: match solution.status { + SelectionMilpStatus::Optimal => SearchStatus::Optimal, + SelectionMilpStatus::TimeLimit => SearchStatus::DeadlineReached, + }, + // The dual bound is on the objective, which leaves out the + // request fees every plan pays; the selection's cost has them. + cost_lower_bound_per_second: solution + .cost_lower_bound_per_second + .is_finite() + .then_some(solution.cost_lower_bound_per_second + solution.request_cost_per_second), + milp_variables: solution.size.variables, + milp_constraints: solution.size.rows, + model_construction_ms: solution.build_ms + solution.warm_start_ms, + solver_ms: solution.solve_ms, + validation_ms, + inexact_objective, + }, + }) +} + +/// The selection MILP's error as Stage 3's. +fn selection_error(error: SelectionMilpError) -> ErrorSelectingPlan { + let infeasible = |id: String, cause: RejectionCause, reason: String| { + ErrorSelectingPlan::NoValidCandidate(vec![Rejection { id, cause, reason }]) + }; + match error { + SelectionMilpError::UnsupportedCostModel(model) => { + ErrorSelectingPlan::UnsupportedCostModel(model.to_string()) + } + SelectionMilpError::EntryCount { roots, entries } => ErrorSelectingPlan::EntryCount { + candidate: "the physical memo".into(), + roots, + entries, + }, + SelectionMilpError::Infeasible => infeasible( + "workload".into(), + RejectionCause::Infeasible, + "no plan meets every query's accuracy target, latency bound and the reader \ + constraints together (the selection MILP is infeasible)" + .into(), + ), + SelectionMilpError::NoSolution { status } => ErrorSelectingPlan::NoPlanByDeadline(format!( + "HiGHS stopped ({status}) without an integral plan" + )), + SelectionMilpError::DeadlineReached => { + ErrorSelectingPlan::NoPlanByDeadline("the deadline passed before the solve".into()) + } + SelectionMilpError::Cost(reason) | SelectionMilpError::Solver(reason) => { + ErrorSelectingPlan::Solver(reason) + } + } +} + +/// What Stage 3 removes before the MILP: dominated candidates, and those +/// `asap-cost` could not cost (the model leaves them out), in candidate +/// order. +fn pruned_before_the_milp( + physical: &PhysicalASAPMemoCandidates, + inputs: &MilpInputs, +) -> Vec> { + let mut reasons: BTreeMap = inputs + .pruned_candidates + .iter() + .map(|pruned| (pruned.class, PruneReason::DominatedBy(pruned.dominated_by))) + .collect(); + for (id, data) in inputs.candidate_data.iter().enumerate() { + if data.usage.is_err() { + reasons + .entry(PhysicalCandidateId(id)) + .or_insert(PruneReason::UsageNotCostable); + } + } + reasons + .into_iter() + .map(|(candidate, reason)| PrunedCandidate { + group: physical.memo_group_of(candidate), + candidate, + reason, + }) + .collect() +} + +/// `selected` realized through `realizer`; a rejection is a Stage 2 +/// catalog bug. +pub(crate) fn realize( + physical: &PhysicalASAPMemoCandidates, + selected: &DagOfSelectedCandidates, + realizer: &dyn AssignmentRealizer, +) -> Result { + realizer + .realize(physical, selected) + .map_err(|error| match error { + PhysicalCandidateError::RejectedAssignment(reason) => { + ErrorSelectingPlan::RealizerRejected(reason) + } + error => ErrorSelectingPlan::RealizerRejected(error.to_string()), + }) +} + +/// The selected nodes whose summary (`SummaryAgg` or `PaneChain`) reads +/// update weights `data` proves non-negative although the candidate leaves +/// the requirement open. +pub(crate) fn proven_non_negative_weights( + realized: &RealizedPlan, + data: &DataWorkload, +) -> Vec { + realized + .operator_node_of_selected_node + .iter() + .enumerate() + .filter(|(_, node)| match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { input, .. } | ASAPOp::PaneChain { input, .. }) => { + confirmed_weights(&input.weight_domain, data) != input.weight_domain + } + _ => false, + }) + .map(|(node, _)| SelectedNodeId(node)) + .collect() +} + +#[cfg(test)] +mod tests; + +/// Appends one JSON line describing this run to `ASAP_STAGE3_REPORT`, if +/// set (measurement only; failures to write are ignored). +fn report_run( + solution: &crate::milp::model::SelectionMilpSolution, + objective_per_second: f64, + plan_cost_per_second: f64, +) { + let Ok(path) = std::env::var("ASAP_STAGE3_REPORT") else { + return; + }; + let line = serde_json::json!({ + "status": format!("{:?}", solution.status), + "variables": solution.size.variables, + "binaries": solution.size.binaries, + "rows": solution.size.rows, + "nonzeros": solution.size.nonzeros, + "scopes": solution.size.scopes.iter().map(|(scope, size)| (format!("{scope:?}"), size)).collect::>(), + "families": solution.size.families, + "inexact": solution.size.inexact, + "build_ms": solution.build_ms, + "warm_start_ms": solution.warm_start_ms, + "solve_ms": solution.solve_ms, + "warm_start_cost_per_second": solution.warm_start_cost_per_second, + "objective_per_second": objective_per_second, + "plan_cost_per_second": plan_cost_per_second, + "dual_bound_per_second": solution.cost_lower_bound_per_second, + "relative_gap": solution.relative_gap, + }); + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(file, "{line}"); + } +} diff --git a/crates/plan-selection/src/select/tests.rs b/crates/plan-selection/src/select/tests.rs new file mode 100644 index 000000000..1f2add61f --- /dev/null +++ b/crates/plan-selection/src/select/tests.rs @@ -0,0 +1,175 @@ +use super::*; +use crate::milp::model::tests::{approximate, cost_inputs, shared_sum, through_stages, Fixture}; +use asap_accuracy::{AccuracyModel, BuiltinAccuracyModel, NoAccuracyEvidence}; +use asap_types::ir::properties::{AccuracyGuarantee, AccuracyTarget}; +use asap_types::stages::RealizedPlan; + +fn realizer(fixture: &Fixture) -> impl AssignmentRealizer + '_ { + asap_physical_asap_optimization::Stage2AssignmentRealizer { + original_roots: &fixture.roots, + entries: &fixture.entries, + data: &fixture.data, + query_time_results_retained: fixture.query_time_results_retained, + } +} + +fn select( + fixture: &Fixture, + accuracy: &dyn AccuracyModel, + realizer: &dyn AssignmentRealizer, +) -> Result { + select_plan_with_milp( + &fixture.physical, + &fixture.entries, + &fixture.data, + &AccuracyCheckInputs { + model: accuracy, + evidence: &NoAccuracyEvidence, + }, + &cost_inputs(), + realizer, + None, + ) +} + +/// `fixture` with metric `m` declared non-negative. +fn non_negative(mut fixture: Fixture) -> Fixture { + let source = fixture.data.sources.get_mut("m").expect("m"); + source.values_non_negative = true; + fixture +} + +// The new search's selection: an optimal, valid plan whose cost is +// `plan_cost`'s, the MILP's size and every query's accepted accuracy. The +// ratio of quantiles is a regression of #1127: the A1 rows admitted two +// KLL sketches at parts = 2, whose rank errors compose no guarantee through +// the quotient, so the search returned `SelectedPlanFailedCheck`. +#[test] +fn the_milp_search_selects_a_checked_plan() { + let ratio = || { + through_stages( + &[approximate( + "quantile_over_time(0.5, m[5m]) / quantile_over_time(0.9, m[5m])", + 60_000, + 0.01, + )], + true, + ) + }; + for fixture in [ + shared_sum(), + through_stages( + &[approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.01)], + true, + ), + ratio(), + non_negative(ratio()), + ] { + let stage2 = realizer(&fixture); + let selection = select(&fixture, &BuiltinAccuracyModel, &stage2).unwrap(); + selection.selected_dag.validate(&fixture.physical).unwrap(); + let report = &selection.search_report; + assert_eq!(report.status, SearchStatus::Optimal); + assert_eq!(report.inexact_objective, None); + assert!(report.milp_variables > 0 && report.milp_constraints > 0); + let cost = selection.cost.cost_per_second.unwrap(); + assert!(report.relative_gap(cost).unwrap() <= 1e-6, "{report:?}"); + assert_eq!( + selection.accepted_accuracy_per_query.len(), + fixture.entries.len() + ); + for pruned in &selection.pruned_candidates { + assert!(matches!( + pruned.reason, + PruneReason::DominatedBy(_) | PruneReason::UsageNotCostable + )); + assert_eq!( + pruned.group, + fixture.physical.memo_group_of(pruned.candidate) + ); + } + } +} + +/// A realizer that rejects every selection. +struct Rejecting; + +impl AssignmentRealizer for Rejecting { + fn realize( + &self, + _physical: &PhysicalASAPMemoCandidates, + _selected: &DagOfSelectedCandidates, + ) -> Result { + Err(PhysicalCandidateError::RejectedAssignment( + "a combination the catalog allows".into(), + )) + } +} + +// A realizer rejection is a Stage 2 catalog bug: an error, no re-solve. +#[test] +fn a_realizer_rejection_is_realizer_rejected() { + let fixture = shared_sum(); + match select(&fixture, &BuiltinAccuracyModel, &Rejecting) { + Err(ErrorSelectingPlan::RealizerRejected(reason)) => { + assert!(reason.contains("the catalog allows"), "{reason}") + } + other => panic!("{other:?}"), + } +} + +/// An accuracy model that accepts no guarantee, exact included, and never +/// accepts an output otherwise: the A1 rows still run, so the MILP's plan +/// misses `satisfies`, as it would with a wrong weight table. +struct AcceptsNothing; + +impl AccuracyModel for AcceptsNothing { + fn satisfies(&self, _guarantee: &AccuracyGuarantee, _target: &AccuracyTarget) -> bool { + false + } + + fn may_accept_regardless_of_guarantee(&self) -> bool { + false + } +} + +// A selected plan that misses `satisfies` is an error, never returned. +#[test] +fn an_accuracy_miss_is_an_error() { + let fixture = shared_sum(); + let stage2 = realizer(&fixture); + match select(&fixture, &AcceptsNothing, &stage2) { + Err(ErrorSelectingPlan::SelectedPlanFailedCheck { cause, .. }) => { + assert!(cause.failed_a_check(), "{cause:?}") + } + other => panic!("{other:?}"), + } +} + +// Cost models 2 and 3 are not supported by the new search. +#[test] +fn cost_model_2_is_unsupported() { + let fixture = shared_sum(); + let costs = CostInputs { + cost_model: asap_cost::CostModel::ProvisionedCapacity( + asap_cost::ResourceUnitCost::illustrative(), + ), + ..cost_inputs() + }; + let result = select_plan_with_milp( + &fixture.physical, + &fixture.entries, + &fixture.data, + &AccuracyCheckInputs { + model: &BuiltinAccuracyModel, + evidence: &NoAccuracyEvidence, + }, + &costs, + &Rejecting, + None, + ); + assert!(matches!( + result, + Err(ErrorSelectingPlan::UnsupportedCostModel(_)) + )); +} diff --git a/crates/planner/src/run.rs b/crates/planner/src/run.rs index 36ee974b6..8a1bb7357 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -615,6 +615,22 @@ fn clone_error(error: &ErrorSelectingPlan) -> ErrorSelectingPlan { ErrorSelectingPlan::RealizerRejected(reason) => { ErrorSelectingPlan::RealizerRejected(reason.clone()) } + ErrorSelectingPlan::UnsupportedCostModel(model) => { + ErrorSelectingPlan::UnsupportedCostModel(model.clone()) + } + &ErrorSelectingPlan::ObjectiveDiffersFromPlanCost { + objective_per_second, + plan_cost_per_second, + } => ErrorSelectingPlan::ObjectiveDiffersFromPlanCost { + objective_per_second, + plan_cost_per_second, + }, + ErrorSelectingPlan::SelectedPlanFailedCheck { cause, reason } => { + ErrorSelectingPlan::SelectedPlanFailedCheck { + cause: *cause, + reason: reason.clone(), + } + } error => ErrorSelectingPlan::Solver(error.to_string()), } } diff --git a/crates/types/src/stages/pruning.rs b/crates/types/src/stages/pruning.rs index 8b058f788..f857460f4 100644 --- a/crates/types/src/stages/pruning.rs +++ b/crates/types/src/stages/pruning.rs @@ -2,10 +2,12 @@ //! Each stage removes only what its own knowledge rules out (option C): //! Pass 1 what the measure or the deployment cannot compute, Stage 2 what //! cannot be implemented, built or kept. Stage 0 fails with an error and -//! Pass 2 only adds candidates, so neither prunes. Stage 3's rejections -//! keep their own cause (`RejectionCause` in plan selection). +//! Pass 2 only adds candidates, so neither prunes. Stage 3 prunes before +//! its selection MILP (dominated and uncostable candidates); its +//! rejections of whole plans keep their own cause (`RejectionCause` in plan +//! selection). -use super::MemoGroupId; +use super::{MemoGroupId, PhysicalCandidateId}; /// One candidate a stage removed from memo group `group`, and why. `C` is /// the stage's candidate type: Pass 1's way to compute a measure @@ -21,8 +23,8 @@ pub struct PrunedCandidate { /// Why a stage removed a candidate. The first seven are Pass 1's: the /// candidate cannot compute its measure, the deployment cannot run it, or /// no query that reads it can meet its accuracy target with it. -/// The rest are Stage 2's: the physical candidate cannot be built, used or -/// kept. +/// Then Stage 2's: the physical candidate cannot be built, used or +/// kept. The last two are Stage 3's, before its selection MILP. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PruneReason { /// Pass 1: the measure requires an exact answer and the summary is @@ -87,4 +89,12 @@ pub enum PruneReason { /// (`MatAtQuery`), which needs at least two reading queries, and fewer /// queries can reach it. TooFewQueryReaders, + /// Stage 3: the candidate it names, in the same memo group with the + /// same producers and readers, is at least as good in every respect + /// (guarantee, usage, latency, output, held state), so no optimal plan + /// needs this one. + DominatedBy(PhysicalCandidateId), + /// Stage 3: `asap-cost` could not cost it, so the selection MILP leaves + /// it out. + UsageNotCostable, } diff --git a/tools/dag-viewer/stages.js b/tools/dag-viewer/stages.js index b8e4472f8..57a672aae 100644 --- a/tools/dag-viewer/stages.js +++ b/tools/dag-viewer/stages.js @@ -264,7 +264,7 @@ const FAILED_CHECK_CAUSES = [ 'accuracy_target_missed', 'count_min_weights_not_proven_non_negative', 'no_accuracy_guarantee', 'latency_bound_exceeded', 'latency_over_repeat_interval', - 'over_cluster_capacity', + 'over_cluster_capacity', 'infeasible', ]; // Every `reference_pricing.pruned_candidates[].cause` the reference pricer // writes: the failed checks, then the losses under the cost model (priced, From 1b6685928285ce330795f7d03f14b0af7a04742b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:09:54 +0000 Subject: [PATCH 2/9] fix(plan-selection): a shareable candidate reads a scan at its own window; window-independent scans keep their usage per window (#1095) --- crates/plan-selection/src/groups.rs | 18 +++++++++++---- crates/plan-selection/src/milp/cost_terms.rs | 24 +++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs index 856fe3c5b..852dbc4b3 100644 --- a/crates/plan-selection/src/groups.rs +++ b/crates/plan-selection/src/groups.rs @@ -1000,12 +1000,22 @@ fn scan_window_options( } } } + // A shareable candidate reading the scan as its own input reads it + // at its own window; a read as an output reads it whole. + if let ContextKind::Shared(own) = context.kind { + if producers_per_input[own.0] + .iter() + .any(|producers| producers.contains(&scan)) + { + let window = window_of(own); + if !kinds.contains(&window) { + kinds.push(window); + } + } + } let read_whole = match context.kind { ContextKind::Query(_) => output_candidates.iter().any(|out| out.contains(&scan)), - ContextKind::Shared(own) => producers_per_input[own.0] - .iter() - .any(|producers| producers.contains(&scan)), - ContextKind::Ingestion => false, + ContextKind::Shared(_) | ContextKind::Ingestion => false, }; if read_whole && !kinds.contains(&ScanWindow::Unbounded) { kinds.push(ScanWindow::Unbounded); diff --git a/crates/plan-selection/src/milp/cost_terms.rs b/crates/plan-selection/src/milp/cost_terms.rs index 4d8bf050e..3be9a9ecb 100644 --- a/crates/plan-selection/src/milp/cost_terms.rs +++ b/crates/plan-selection/src/milp/cost_terms.rs @@ -519,7 +519,11 @@ fn window_terms( let ContextKind::Shared(own) = milp.inputs.contexts[reading].kind else { continue; }; - (ScanWindow::Unbounded, Expression::from(milp.selected[&own])) + // `m` itself reads the scan: at its own window. + ( + window_union(reader_windows(milp, scan, own)), + Expression::from(milp.selected[&own]), + ) } }; reads.push((reading, window, indicator)); @@ -532,11 +536,25 @@ fn window_terms( } } let usage_at = |milp: &SelectionMilp<'_>, window: ScanWindow| { - milp.inputs.candidate_data[scan.0] + let found = milp.inputs.candidate_data[scan.0] .usage_per_window .iter() .find(|(known, _, _)| *known == window) - .map(|&(_, usage, output)| (NodeNumbers::of(&usage), output)) + .map(|&(_, usage, output)| (NodeNumbers::of(&usage), output)); + if found.is_none() { + super::warm_start::debug(|| { + format!( + "scan {} has no usage at {window:?}; costed at {:?}", + scan.0, + milp.inputs.candidate_data[scan.0] + .usage_per_window + .iter() + .map(|(w, _, _)| *w) + .collect::>() + ) + }); + } + found }; let terms = match kinds.len() { 0 => None, From 1566f6aab2b82cb072f6ee126caa9fd59b6f9aea Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:28:13 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix(plan-selection):=20adopt=20main's=20Sta?= =?UTF-8?q?ge=201=20exports=20and=20cost=5Finputs=20name=20after=20rebasin?= =?UTF-8?q?g=20onto=20the=20Stages=200=E2=80=932=20milestone=20(#1095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/plan-selection/src/groups.rs | 6 +++--- crates/plan-selection/src/groups/tests.rs | 11 ++++------- crates/plan-selection/src/milp/model/tests.rs | 11 ++++------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs index 852dbc4b3..997e6bed3 100644 --- a/crates/plan-selection/src/groups.rs +++ b/crates/plan-selection/src/groups.rs @@ -510,21 +510,21 @@ pub(crate) fn prepare_milp_inputs( let models = SelectionModels { accuracy: &asap_accuracy::BuiltinAccuracyModel, evidence: &asap_accuracy::NoAccuracyEvidence, - plan_selection: cost_inputs, + cost_inputs, }; prepare_milp_inputs_with(physical, entries, data, &models) } /// [`MilpInputs`] for `physical`'s candidates, its accuracy positions /// checked with `models` (the deployment's accuracy model and evidence) and -/// its usage costed with `models.plan_selection`. +/// its usage costed with `models.cost_inputs`. pub(crate) fn prepare_milp_inputs_with( physical: &PhysicalASAPMemoCandidates, entries: &[QueryWorkloadEntry], data: &DataWorkload, models: &SelectionModels<'_>, ) -> MilpInputs { - let cost_inputs = models.plan_selection; + let cost_inputs = models.cost_inputs; let links = &CandidateLinks::new(physical); let class_count = physical.candidates.len(); let all_alive = vec![true; class_count]; diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index 9d6326e8e..80c9c2574 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use super::*; -use asap_logical_asap_optimization::pass2::memo::shared_configurations::PaneWidthDomain; +use asap_logical_asap_optimization::PaneWidthDomain; use asap_types::ir::properties::{AccuracyMetric, BoundExpr, ProbabilityExpr}; use asap_types::ir::scalar::expr_ir::ScalarValue; use asap_types::ir::schema::SketchAlgorithm; @@ -553,12 +553,9 @@ fn through_stages_1_and_2(queries: &[&str]) -> ThroughStages1And2 { /// [`through_stages_1_and_2`] over the pane-width domain `domain`. fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> ThroughStages1And2 { - use asap_logical_asap_optimization::pass1::logical_memo_candidates::generate_per_computation_logical_memo_candidates; - use asap_logical_asap_optimization::pass2::logical_memo_candidates::{ - generate_logical_asap_memo_candidates, Pass2RoundState, - }; - use asap_logical_asap_optimization::pass2::memo::DagOfLogicalMemoGroups; + use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_logical_asap_optimization::{generate_logical_asap_memo_candidates, Pass2RoundState}; use asap_types::ir::properties::AccuracyTarget; use asap_types::workload::{ DurationMs, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, @@ -583,7 +580,7 @@ fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> Thr source.values_non_negative = true; let targets = vec![target.clone(); queries.len()]; let pass1 = generate_per_computation_logical_memo_candidates( - DagOfLogicalMemoGroups::from_roots(&roots), + asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&roots), &targets, &asap_accuracy::BuiltinAccuracyModel, &asap_accuracy::NoAccuracyEvidence, diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs index 9b5980295..861a11a2e 100644 --- a/crates/plan-selection/src/milp/model/tests.rs +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -84,13 +84,10 @@ fn through_stages_over( ingesting: bool, values_non_negative: bool, ) -> Fixture { - use asap_logical_asap_optimization::pass1::logical_memo_candidates::generate_per_computation_logical_memo_candidates; - use asap_logical_asap_optimization::pass2::logical_memo_candidates::{ - generate_logical_asap_memo_candidates, Pass2RoundState, - }; - use asap_logical_asap_optimization::pass2::memo::shared_configurations::PaneWidthDomain; - use asap_logical_asap_optimization::pass2::memo::DagOfLogicalMemoGroups; + use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_logical_asap_optimization::PaneWidthDomain; + use asap_logical_asap_optimization::{generate_logical_asap_memo_candidates, Pass2RoundState}; use asap_types::ir::properties::AccuracyTarget; use asap_types::workload::{ Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, @@ -128,7 +125,7 @@ fn through_stages_over( } let targets: Vec = queries.iter().map(target).collect(); let pass1 = generate_per_computation_logical_memo_candidates( - DagOfLogicalMemoGroups::from_roots(&roots), + asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&roots), &targets, &asap_accuracy::BuiltinAccuracyModel, &asap_accuracy::NoAccuracyEvidence, From f3d83bb117b43d9e39621c55e43861e0d3e08cc4 Mon Sep 17 00:00:00 2001 From: Zeying Zhu <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:04:41 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(plan-selection):=20selection=20MILP=20?= =?UTF-8?q?solved=20per=20independent=20part,=20LP=20gap=20diagnosis,=20ti?= =?UTF-8?q?ghter=20big-M=20bounds=20(#1095=20=C2=A75=203a,=20=C2=A76.1)=20?= =?UTF-8?q?(#1155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each connected component of the selection MILP is solved alone and joined before the single decode, realization and plan_cost check. An lp-diagnosis feature (devtools only) attributes the LP gap by row and variable family. Two big-M bounds are tightened. No selected plan or cost changes. --- crates/devtools/Cargo.toml | 5 + crates/plan-selection/Cargo.toml | 5 + crates/plan-selection/src/milp/cost_terms.rs | 37 +- .../plan-selection/src/milp/lp_diagnosis.rs | 303 ++++++++++ crates/plan-selection/src/milp/mod.rs | 6 +- crates/plan-selection/src/milp/model.rs | 548 +++++++++++++++--- crates/plan-selection/src/milp/model/tests.rs | 169 +++++- crates/plan-selection/src/select.rs | 6 +- 8 files changed, 978 insertions(+), 101 deletions(-) create mode 100644 crates/plan-selection/src/milp/lp_diagnosis.rs diff --git a/crates/devtools/Cargo.toml b/crates/devtools/Cargo.toml index 65a65831e..8c71ffb2f 100644 --- a/crates/devtools/Cargo.toml +++ b/crates/devtools/Cargo.toml @@ -32,6 +32,11 @@ serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } +[features] +# Each Stage 3 selection MILP solve also reports where its LP relaxation +# loses its bound, with `ASAP_STAGE3_LP_DIAGNOSIS=` (measurement only). +lp-diagnosis = ["asap-plan-selection/lp-diagnosis"] + [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-physical-asap-optimization = { path = "../physical-asap-optimization", features = ["test-support"] } diff --git a/crates/plan-selection/Cargo.toml b/crates/plan-selection/Cargo.toml index 193554f1f..ac64258a5 100644 --- a/crates/plan-selection/Cargo.toml +++ b/crates/plan-selection/Cargo.toml @@ -25,6 +25,11 @@ highs = "=2.4.0" # `reference_pricer`: the whole-workload selector over enumerated plans, for # the developer tools' reference pricing (`asap-devtools`). reference-pricer = [] +# `lp-diagnosis`: with `ASAP_STAGE3_LP_DIAGNOSIS=`, each selection MILP +# solve also reports where its LP relaxation loses its bound (measurement +# only, for the developer tools; a diagnosed run's timings are not +# comparable with an undiagnosed one's). +lp-diagnosis = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } diff --git a/crates/plan-selection/src/milp/cost_terms.rs b/crates/plan-selection/src/milp/cost_terms.rs index 3be9a9ecb..dbc2483ab 100644 --- a/crates/plan-selection/src/milp/cost_terms.rs +++ b/crates/plan-selection/src/milp/cost_terms.rs @@ -63,7 +63,9 @@ use asap_types::workload::bounds::source_name; use asap_types::workload::{QueryRecurrence, RepeatedDemand}; use good_lp::{constraint, Expression, IntoAffineExpression, Variable}; -use crate::groups::{CandidateUsage, ContextKind, RunKind, UsageAndOutput, UsageContext}; +use crate::groups::{ + CandidateUsage, ContextKind, ResultSlot, RunKind, UsageAndOutput, UsageContext, +}; use super::model::{Reader, Scope, SelectionMilp, SelectionMilpError}; @@ -985,8 +987,10 @@ pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), Selec } }; // Per query-time shareable context: the execution cost of its runs - // other than `m`'s plain own term, and the most it can be. - let mut context_execution: BTreeMap = BTreeMap::new(); + // other than `m`'s plain own term, and per slot (`None`: `m`'s own run) + // the most it can be. + let mut context_execution: BTreeMap, f64>)> = + BTreeMap::new(); // `H[m]` per query-time shareable context. let mut held_time: HashMap = HashMap::new(); let runs = milp.runs(); @@ -996,6 +1000,9 @@ pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), Selec let candidate_data = &milp.physical.candidates[candidate.0]; let (timing, materialization) = (candidate_data.timing, candidate_data.materialization); let own = milp.inputs.contexts[context].kind == ContextKind::Shared(candidate); + // The most this run adds to its context's execution cost: all its + // terms at 1. + let mut run_execution = 0.0; for term in &terms { let numbers = term.numbers; charge(&term.indicator, numbers.standing_cost); @@ -1008,11 +1015,11 @@ pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), Selec charge(&component.into(), numbers.execution_cost * rate); } } else { - let (sum, most) = context_execution + let (sum, _) = context_execution .entry(context) - .or_insert_with(|| (Expression::from(0.0), 0.0)); + .or_insert_with(|| (Expression::from(0.0), BTreeMap::new())); *sum += numbers.execution_cost * term.indicator.clone(); - *most += numbers.execution_cost; + run_execution += numbers.execution_cost; } } } @@ -1093,11 +1100,25 @@ pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), Selec ContextKind::Ingestion => {} } } + if let Some((_, most_per_slot)) = context_execution.get_mut(&context) { + let slot = (!own).then(|| milp.inputs.slot[candidate.0]); + let entry = most_per_slot.entry(slot).or_default(); + *entry = entry.max(run_execution); + } } // A context's execution at its varying rate: `V ≥ E − Ē (x[m] − z)` per - // component `z`. + // component `z`, `Ē` the most `E` can be: per slot, its costliest run. milp.builder.scope = Scope::SharedContexts; - for (context, (sum, most)) in context_execution { + for (context, (sum, most_per_slot)) in context_execution { + let most: f64 = if super::model::tests_loose_big_m() { + // Before: every term's cost. + sum.clone() + .linear_coefficients() + .map(|(_, cost)| cost) + .sum() + } else { + most_per_slot.values().sum() + }; let ContextRate::Components(components) = &rates[context] else { continue; }; diff --git a/crates/plan-selection/src/milp/lp_diagnosis.rs b/crates/plan-selection/src/milp/lp_diagnosis.rs new file mode 100644 index 000000000..c946d61b4 --- /dev/null +++ b/crates/plan-selection/src/milp/lp_diagnosis.rs @@ -0,0 +1,303 @@ +//! #1095 §6.1: where the selection MILP's LP relaxation loses its bound +//! (measurement only, behind the `lp-diagnosis` feature). With +//! `ASAP_STAGE3_LP_DIAGNOSIS=`, each solve appends one JSON line: +//! - `lp`: the LP relaxation of the model as built, and `root`: HiGHS's +//! bound after its root node (presolve, cuts), both from the warm start; +//! - `lp_without`: the LP with one row family dropped, per family: how much +//! of the LP bound that family's rows hold up; +//! - `lp_tightened`: the LP with valid rows added: `Σ terms ≥ run` for each +//! run charged only on its configuration or window terms (at integral +//! binaries one of them is 1); +//! - `objective_by_family`: the objective split by the family of each +//! charged variable, at the LP's solution, at the warm start and at the +//! incumbent, so `incumbent − lp` splits into one share per family; +//! - `runs`: the charged `p` and `x` variables with the largest difference +//! between the incumbent and the LP, with their values in both. +//! +//! The deadline is pushed back by the diagnosis's time, so the MILP keeps +//! its budget; a diagnosed run's wall time (and the facade's later rounds) +//! is still not comparable with an undiagnosed one. +use std::collections::BTreeMap; +use std::time::Instant; + +use good_lp::solvers::highs::highs; +use good_lp::{constraint, variable, Constraint, Expression, ProblemVariables, SolverModel}; + +use super::model::{objective_scale, SelectionMilp, HIGHS_THREADS}; + +/// The environment variable naming the file the diagnosis appends to. +const DIAGNOSIS_ENV: &str = "ASAP_STAGE3_LP_DIAGNOSIS"; + +/// The runs listed per solve. +const RUNS_SHOWN: usize = 40; + +/// The time limit of each LP and of the root node. +const LP_TIME_LIMIT_S: f64 = 300.0; + +/// One LP (or root node) solved. +#[derive(Debug, Clone, serde::Serialize)] +struct Bound { + /// The bound, cost per second (`None` when HiGHS gave none). + per_second: Option, + status: String, + ms: u64, +} + +/// The LP solves, before the MILP's. +pub(crate) struct Diagnosis { + path: String, + lp: Bound, + root: Bound, + lp_without: BTreeMap<&'static str, Bound>, + lp_tightened: Bound, + tightening_rows: usize, + /// The LP's columns. + lp_columns: Vec, + /// The objective, by column. + objective: Vec<(usize, f64)>, + column_families: Vec<&'static str>, + /// Per `p` and `x` column, what it stands for. + labels: BTreeMap, +} + +impl Diagnosis { + /// The LP solves of `milp` from `start` (columns), when the diagnosis is + /// asked for. + pub(crate) fn run(milp: &SelectionMilp<'_>, start: Option<&[f64]>) -> Option { + let path = std::env::var(DIAGNOSIS_ENV).ok()?; + let definitions: Vec<_> = milp + .builder + .variables + .iter_variables_with_def() + .map(|(variable, definition)| (variable, definition.clone())) + .collect(); + let column_of: std::collections::HashMap<_, usize> = definitions + .iter() + .enumerate() + .map(|(column, (variable, _))| (*variable, column)) + .collect(); + let objective: Vec<(usize, f64)> = milp + .objective_terms + .iter() + .map(|&(variable, coefficient)| (column_of[&variable], coefficient)) + .collect(); + let start_values: Option> = start.map(|start| { + definitions + .iter() + .map(|(variable, _)| *variable) + .zip(start.iter().copied()) + .collect() + }); + let scale = objective_scale(&milp.objective_terms, start_values.as_deref()); + // The relaxation: the same variables (same indices), none integral. + let relaxed = || { + let mut variables = ProblemVariables::new(); + for (_, definition) in &definitions { + variables.add( + variable() + .min(definition.get_min()) + .max(definition.get_max()), + ); + } + variables + }; + let objective_expression: Expression = milp + .objective_terms + .iter() + .map(|&(variable, coefficient)| coefficient * scale * variable) + .sum(); + let solve = |variables: ProblemVariables, rows: Vec, root: bool| { + let started = Instant::now(); + let problem = variables + .minimise(objective_expression.clone()) + .using(highs) + .with_all(rows); + let mut model = problem.try_into_inner().expect("a HiGHS model"); + model.set_option("output_flag", false); + model.set_option("threads", HIGHS_THREADS); + model.set_option("time_limit", LP_TIME_LIMIT_S); + if root { + // The root node, then stop: its bound is the root bound. + model.set_option("mip_max_nodes", 1); + model.set_option("mip_rel_gap", 0.0); + model.set_option("mip_abs_gap", 0.0); + if let Some(start) = start { + let _ = model.try_set_solution(Some(start), None, None, None); + } + } + let Ok(solved) = model.try_solve() else { + return ( + Bound { + per_second: None, + status: "solver error".into(), + ms: started.elapsed().as_millis() as u64, + }, + Vec::new(), + ); + }; + let value = if root { + solved.double_info_value(c"mip_dual_bound").ok() + } else if solved.status() == highs::HighsModelStatus::Optimal { + Some(solved.objective_value()) + } else { + None + }; + let columns = if root { + Vec::new() + } else { + solved.get_solution().columns().to_vec() + }; + ( + Bound { + per_second: value.filter(|v| v.is_finite()).map(|v| v / scale), + status: format!("{:?}", solved.status()), + ms: started.elapsed().as_millis() as u64, + }, + columns, + ) + }; + let rows = &milp.builder.rows; + let families = &milp.builder.row_families; + let (lp, lp_columns) = solve(relaxed(), rows.clone(), false); + let (root, _) = solve(milp.builder.variables.clone(), rows.clone(), true); + let mut lp_without = BTreeMap::new(); + let present: std::collections::BTreeSet<&'static str> = families.iter().copied().collect(); + for family in present { + let kept: Vec = rows + .iter() + .zip(families) + .filter(|(_, &f)| f != family) + .map(|(row, _)| row.clone()) + .collect(); + lp_without.insert(family, solve(relaxed(), kept, false).0); + } + let tightening = cover_rows(milp); + let tightening_rows = tightening.len(); + let mut tightened = rows.clone(); + tightened.extend(tightening); + let (lp_tightened, _) = solve(relaxed(), tightened, false); + let candidate = |c: asap_types::stages::PhysicalCandidateId| { + let data = &milp.physical.candidates[c.0]; + format!( + "c{} {} {:?} {:?}", + c.0, + milp.physical.physical_node(c).operator.kind_name(), + data.timing, + data.materialization + ) + }; + let mut labels = BTreeMap::new(); + for (context, choices) in milp.choice.iter().enumerate() { + let place = match milp.inputs.contexts[context].kind { + crate::groups::ContextKind::Query(query) => format!("q{query}"), + crate::groups::ContextKind::Ingestion => "I".to_string(), + crate::groups::ContextKind::Shared(m) => format!("m{}", m.0), + }; + for (&c, variable) in choices { + labels.insert(column_of[variable], format!("p[{place}] {}", candidate(c))); + } + } + for (&m, variable) in &milp.selected { + labels.insert(column_of[variable], format!("x {}", candidate(m))); + } + Some(Self { + labels, + path, + lp, + root, + lp_without, + lp_tightened, + tightening_rows, + lp_columns, + objective, + column_families: milp.builder.column_families.clone(), + }) + } + + /// Appends the line, with the warm start's and the incumbent's columns + /// (completed) and the MILP's results. + pub(crate) fn write( + self, + start: Option<&[f64]>, + incumbent: Option<&[f64]>, + objective_per_second: f64, + dual_bound_per_second: f64, + status: &str, + ) { + let by_family = |columns: Option<&[f64]>| -> Option> { + let columns = columns.filter(|columns| !columns.is_empty())?; + let mut split: BTreeMap<&'static str, f64> = BTreeMap::new(); + for &(column, coefficient) in &self.objective { + *split.entry(self.column_families[column]).or_default() += + coefficient * columns[column]; + } + Some(split) + }; + // Per labeled column: (LP charge, incumbent charge, LP value, + // incumbent value). + let mut runs: Vec<(String, f64, f64, f64, f64)> = Vec::new(); + if let Some(incumbent) = incumbent.filter(|columns| !columns.is_empty()) { + let mut charge: BTreeMap = BTreeMap::new(); + for &(column, coefficient) in &self.objective { + if self.labels.contains_key(&column) { + *charge.entry(column).or_default() += coefficient; + } + } + for (column, coefficient) in charge { + let (lp, at) = (self.lp_columns[column], incumbent[column]); + runs.push(( + self.labels[&column].clone(), + coefficient * lp, + coefficient * at, + lp, + at, + )); + } + runs.sort_by(|a, b| (b.2 - b.1).abs().total_cmp(&(a.2 - a.1).abs())); + runs.truncate(RUNS_SHOWN); + } + let line = serde_json::json!({ + "runs": runs, + "variables": self.column_families.len(), + "status": status, + "objective_per_second": objective_per_second, + "dual_bound_per_second": dual_bound_per_second, + "lp": self.lp, + "root": self.root, + "lp_without": self.lp_without, + "lp_tightened": self.lp_tightened, + "tightening_rows": self.tightening_rows, + "objective_by_family": { + "lp": by_family(Some(&self.lp_columns)), + "warm_start": by_family(start), + "incumbent": by_family(incumbent), + }, + }); + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + { + let _ = writeln!(file, "{line}"); + } + } +} + +/// `Σ terms ≥ run` for each run whose terms are all configuration or +/// window terms (none on the run's own indicator): at integral binaries a +/// used run has one term at 1. +fn cover_rows(milp: &SelectionMilp<'_>) -> Vec { + let mut rows = Vec::new(); + for (&(context, candidate), terms) in &milp.run_terms_cache { + if terms.is_empty() || terms.iter().any(|term| term.plain) { + continue; + } + let Some(run) = milp.run_indicator(context, candidate) else { + continue; + }; + let sum: Expression = terms.iter().map(|term| term.indicator.clone()).sum(); + rows.push(constraint!(sum >= run)); + } + rows +} diff --git a/crates/plan-selection/src/milp/mod.rs b/crates/plan-selection/src/milp/mod.rs index c0cf8b822..7c0e9ab2f 100644 --- a/crates/plan-selection/src/milp/mod.rs +++ b/crates/plan-selection/src/milp/mod.rs @@ -12,11 +12,15 @@ //! - [`completion`]: the exact objective of a plan given as binaries, the //! cheapest values of the continuous variables found without a solver; //! - [`decode`]: the solution as a `DagOfSelectedCandidates`, one node per -//! candidate run. +//! candidate run; +//! - `lp_diagnosis` (feature `lp-diagnosis`, measurement only): where the +//! LP relaxation loses its bound. //! //! [`crate::select`] runs them, realizes the plan once and checks it. pub(crate) mod completion; pub(crate) mod cost_terms; pub(crate) mod decode; +#[cfg(feature = "lp-diagnosis")] +pub(crate) mod lp_diagnosis; pub(crate) mod model; pub(crate) mod warm_start; diff --git a/crates/plan-selection/src/milp/model.rs b/crates/plan-selection/src/milp/model.rs index f148955be..ee987788f 100644 --- a/crates/plan-selection/src/milp/model.rs +++ b/crates/plan-selection/src/milp/model.rs @@ -80,6 +80,9 @@ pub(crate) struct SelectionMilpOptions { /// Write the accuracy rows (row 8); off when the facade ignores /// accuracy guarantees. pub(crate) enforce_accuracy: bool, + /// Solve each independent part alone ([`independent_parts`]); off only + /// to compare with one solve in tests. + pub(crate) split_into_parts: bool, } /// Which part of the model a variable or row belongs to, for the size @@ -122,6 +125,9 @@ pub(crate) struct ModelBuilder { pub(crate) scopes: BTreeMap, /// Where new variables and rows are counted. pub(crate) scope: Scope, + /// The family of each variable, in creation order. + #[cfg(feature = "lp-diagnosis")] + pub(crate) column_families: Vec<&'static str>, } impl ModelBuilder { @@ -133,10 +139,16 @@ impl ModelBuilder { families: BTreeMap::new(), scopes: BTreeMap::new(), scope: Scope::Global, + #[cfg(feature = "lp-diagnosis")] + column_families: Vec::new(), } } fn count(&mut self, family: &'static str, size: FamilySize) { + #[cfg(feature = "lp-diagnosis")] + if size.variables > 0 { + self.column_families.push(family); + } self.families.entry(family).or_default().add(size); self.scopes.entry(self.scope).or_default().add(size); } @@ -262,6 +274,8 @@ pub(crate) struct SelectionMilp<'a> { /// producer in the other slot is not justified by `r`. pub(crate) slot_links: BTreeMap<(usize, PhysicalCandidateId, usize), Vec<(ResultSlot, Variable)>>, + /// [`SelectionMilpOptions::split_into_parts`]. + pub(crate) split_into_parts: bool, } /// Variables and rows of one model. @@ -304,6 +318,8 @@ pub(crate) struct SelectionMilpSolution { /// slot it reads. pub(crate) slot_reads: BTreeMap<(usize, PhysicalCandidateId, usize), ResultSlot>, pub(crate) size: SelectionMilpSize, + /// Per independent part, how its solve went, in part order. + pub(crate) parts: Vec, pub(crate) build_ms: u64, /// Building the warm start, HiGHS's first incumbent. pub(crate) warm_start_ms: u64, @@ -489,6 +505,7 @@ pub(crate) fn build_selection_milp<'a>( producer_gives: HashMap::new(), window_cache: HashMap::new(), slot_links: BTreeMap::new(), + split_into_parts: options.split_into_parts, }; choice_variables(&mut milp); structural_rows(&mut milp); @@ -1562,6 +1579,9 @@ fn accuracy_rows(milp: &mut SelectionMilp<'_>) { let exact_target = milp.entries[query].requirements.accuracy.is_exact(); // (candidate, dep, parts) per run `q` may depend on. let mut counted: Vec<(PhysicalCandidateId, Expression, usize)> = Vec::new(); + // Where each counted run gives its value: (context, slot), `None` + // for a shareable candidate's own run. One value per place. + let mut places: BTreeSet<(usize, Option)> = BTreeSet::new(); for &(context, candidate) in &approximate_runs { let Some(weight) = inputs.accuracy_weights[query].get(&candidate).copied() else { continue; @@ -1604,6 +1624,10 @@ fn accuracy_rows(milp: &mut SelectionMilp<'_>) { AccuracyWeight::Unusable(_) => false, }; if usable { + places.insert(match inputs.contexts[context].kind { + ContextKind::Shared(own) if own == candidate => (context, None), + _ => (context, Some(inputs.slot[candidate.0])), + }); counted.push(( candidate, dep, @@ -1625,7 +1649,12 @@ fn accuracy_rows(milp: &mut SelectionMilp<'_>) { milp.builder .row("8 accuracy (pair)", constraint!(one + other <= 1.0)); } - let most = counted.len() as f64; + // The most values `q` can depend on together. + let most = if tests_loose_big_m() { + counted.len() as f64 + } else { + places.len() as f64 + }; if counted.iter().all(|&(_, _, parts)| parts as f64 >= most) { continue; } @@ -1654,8 +1683,155 @@ fn accuracy_rows(milp: &mut SelectionMilp<'_>) { } } -/// Solves `milp` once with HiGHS, with the time left before `deadline` as -/// its time limit, from the warm start. +/// One independent part of the model (#1095 §5, 3a): a connected component +/// of the graph joining the variables of each row, or all components +/// without a query together. Parts share no variable and no row, and the +/// objective is a sum over variables, so solving each alone and joining +/// their solutions solves the whole model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndependentPart { + /// The queries whose output references it covers, ascending. + pub(crate) queries: Vec, + /// Its columns (variables in creation order), ascending. + pub(crate) columns: Vec, + /// Its rows, ascending. + pub(crate) rows: Vec, +} + +/// The independent parts of `milp`, in order of their first query (the +/// part without a query last). Queries fall into one part when they may +/// share a shareable candidate, a run of `I`, a source's raw retention or +/// anything else a row joins. +pub(crate) fn independent_parts(milp: &SelectionMilp<'_>) -> Vec { + let column_of: HashMap = milp + .builder + .variables + .iter_variables_with_def() + .enumerate() + .map(|(column, (variable, _))| (variable, column)) + .collect(); + let count = column_of.len(); + // Union-find over the columns, joined along each row. + let mut parent: Vec = (0..count).collect(); + fn root(parent: &mut [usize], mut node: usize) -> usize { + while parent[node] != node { + parent[node] = parent[parent[node]]; + node = parent[node]; + } + node + } + for row in &milp.builder.rows { + let mut first: Option = None; + for (variable, _) in row.expression().linear_coefficients() { + let column = root(&mut parent, column_of[&variable]); + match first { + None => first = Some(column), + Some(known) if known != column => parent[column] = known, + Some(_) => {} + } + } + } + let output_refs = output_references(&milp.physical.query_roots); + let mut query_of_component: BTreeMap = BTreeMap::new(); + for (reference, &(query, _)) in output_refs.iter().enumerate() { + for out in &milp.inputs.output_candidates[reference] { + if let Some(variable) = milp.choice[query].get(out) { + let component = root(&mut parent, column_of[variable]); + query_of_component.entry(component).or_insert(query); + } + } + } + // The part of each component: its first query's, or the last. + let part_of = |component: usize| query_of_component.get(&component).copied(); + let mut parts: BTreeMap, IndependentPart> = BTreeMap::new(); + let mut part_of_column = Vec::with_capacity(count); + for column in 0..count { + let key = part_of(root(&mut parent, column)); + part_of_column.push(key); + parts + .entry(key) + .or_insert_with(|| IndependentPart { + queries: Vec::new(), + columns: Vec::new(), + rows: Vec::new(), + }) + .columns + .push(column); + } + for (reference, &(query, _)) in output_refs.iter().enumerate() { + let Some(variable) = milp.inputs.output_candidates[reference] + .iter() + .find_map(|out| milp.choice[query].get(out)) + else { + continue; + }; + let key = part_of_column[column_of[variable]]; + let queries = &mut parts.get_mut(&key).expect("its column's part").queries; + if !queries.contains(&query) { + queries.push(query); + } + } + for (index, row) in milp.builder.rows.iter().enumerate() { + // A row without a variable is a constant check: kept in the part + // without a query when there is one (`None` sorts first), else in + // the first query's part. + let key = row + .expression() + .linear_coefficients() + .next() + .map(|(variable, _)| part_of_column[column_of[&variable]]) + .unwrap_or_else(|| *parts.keys().next().expect("a part")); + parts.get_mut(&key).expect("a part").rows.push(index); + } + // `None` sorts first; the part without a query goes last. + let mut parts: Vec<(Option, IndependentPart)> = parts.into_iter().collect(); + parts.sort_by_key(|(key, _)| key.unwrap_or(usize::MAX)); + parts + .into_iter() + .map(|(_, mut part)| { + part.queries.sort_unstable(); + part + }) + .collect() +} + +/// How one part's solve went, for the report. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub(crate) struct PartSolve { + pub(crate) queries: usize, + pub(crate) variables: usize, + pub(crate) binaries: usize, + pub(crate) rows: usize, + pub(crate) status: String, + pub(crate) solve_ms: u64, + /// HiGHS's dual bound on the part's objective (cost per second). + pub(crate) dual_bound_per_second: f64, +} + +/// One part's model ready for HiGHS: its variables, rows and objective +/// terms, renamed to its own columns when the model has several parts. +struct PartModel { + variables: ProblemVariables, + rows: Vec, + objective_terms: Vec<(Variable, f64)>, + /// Per column of the part, the column in the whole model. + columns: Vec, + queries: usize, +} + +/// A part solved: its columns' values, the HiGHS objective and dual bound +/// (cost per second), and the status. +struct SolvedPart { + status: SelectionMilpStatus, + columns: Vec, + objective_per_second: f64, + dual_bound_per_second: f64, + report: PartSolve, +} + +/// Solves `milp` with HiGHS, one solve per independent part +/// ([`independent_parts`]) when `split_into_parts`, each part from the warm +/// start with its share of the time left before `deadline` (by variables). pub(crate) fn solve_selection_milp( milp: SelectionMilp<'_>, deadline: Option, @@ -1672,21 +1848,43 @@ pub(crate) fn solve_selection_milp( let start = warm_start::warm_start(&milp, &completion, Some(search_budget)); let warm_start_ms = warm_started.elapsed().as_millis() as u64; let warm_start_cost_per_second = start.as_ref().map(|start| start.cost_per_second); - let scale = objective_scale( - &milp.objective_terms, - start.as_ref().map(|start| start.values.as_slice()), - ); - let objective: Expression = milp - .objective_terms - .iter() - .map(|&(variable, coefficient)| coefficient * scale * variable) - .sum(); + // The diagnosis's LP solves do not count against the deadline: it is + // pushed back by their time. + #[cfg(feature = "lp-diagnosis")] + let diagnosis_started = Instant::now(); + #[cfg(feature = "lp-diagnosis")] + let diagnosis = { + let column_of: HashMap = milp + .builder + .variables + .iter_variables_with_def() + .enumerate() + .map(|(column, (variable, _))| (variable, column)) + .collect(); + let start_columns = start.as_ref().map(|start| { + let mut columns = vec![0.0; column_of.len()]; + for &(variable, value) in &start.values { + columns[column_of[&variable]] = value; + } + columns + }); + super::lp_diagnosis::Diagnosis::run(&milp, start_columns.as_deref()) + .map(|diagnosis| (diagnosis, start_columns)) + }; + #[cfg(feature = "lp-diagnosis")] + let deadline = deadline.map(|deadline| deadline + diagnosis_started.elapsed()); + let parts = if milp.split_into_parts { + independent_parts(&milp) + } else { + Vec::new() + }; let SelectionMilp { builder, choice, selected, slot_links, request_cost_per_second, + objective_terms, .. } = milp; let ModelBuilder { @@ -1697,100 +1895,163 @@ pub(crate) fn solve_selection_milp( .enumerate() .map(|(column, (variable, _))| (variable, column)) .collect(); - let problem = variables - .minimise(objective.clone()) - .using(highs) - .with_all(rows); - let mut model = problem - .try_into_inner() - .map_err(|error| SelectionMilpError::Solver(error.to_string()))?; - model.set_option("mip_rel_gap", 0.0); - model.set_option("mip_abs_gap", 0.0); - model.set_option("output_flag", false); - // HiGHS's MIP search is mostly serial; a thread per core costs - // gigabytes of address space on a large host (#1036 under 8 GB). - model.set_option("threads", HIGHS_THREADS); - let started_with_plan = start.is_some(); - if let Some(start) = start { - let mut columns = vec![0.0; column_of.len()]; + let column_count = column_of.len(); + let start_columns: Option> = start.map(|start| { + let mut columns = vec![0.0; column_count]; for (variable, value) in start.values { columns[column_of[&variable]] = value; } - model - .try_set_solution(Some(&columns), None, None, None) - .map_err(|status| SelectionMilpError::Solver(format!("warm start: {status:?}")))?; - } - let mut only_read_back = false; - if let Some(deadline) = deadline { - let seconds = deadline - .saturating_duration_since(Instant::now()) - .as_secs_f64(); - if seconds <= 0.0 { - if !started_with_plan { - return Err(SelectionMilpError::DeadlineReached); + columns + }); + let models: Vec = if parts.len() <= 1 { + vec![PartModel { + variables, + rows, + objective_terms, + columns: (0..column_count).collect(), + queries: parts.first().map_or(0, |part| part.queries.len()), + }] + } else { + let definitions: Vec<_> = variables + .iter_variables_with_def() + .map(|(_, definition)| definition.clone()) + .collect(); + let mut part_of_column = vec![0; column_count]; + let mut renamed: Vec> = vec![None; column_count]; + let mut models: Vec = parts + .iter() + .enumerate() + .map(|(index, part)| { + let mut variables = ProblemVariables::new(); + for &column in &part.columns { + part_of_column[column] = index; + renamed[column] = Some(variables.add(definitions[column].clone())); + } + PartModel { + variables, + rows: Vec::with_capacity(part.rows.len()), + objective_terms: Vec::new(), + columns: part.columns.clone(), + queries: part.queries.len(), + } + }) + .collect(); + let rename = |variable: Variable| renamed[column_of[&variable]].expect("in a part"); + for (index, part) in parts.iter().enumerate() { + for &row in &part.rows { + let row = &rows[row]; + let mut expression = Expression::from(row.expression().constant()); + for (variable, coefficient) in row.expression().linear_coefficients() { + expression.add_mul(coefficient, rename(variable)); + } + models[index].rows.push(if row.is_equality() { + constraint!(expression == 0.0) + } else { + constraint!(expression <= 0.0) + }); } - only_read_back = true; } - model.set_option("time_limit", seconds.max(MOMENT_TO_READ_THE_START_S)); - } - let started = Instant::now(); - let solved = model - .try_solve() - .map_err(|status| SelectionMilpError::Solver(format!("{status:?}")))?; - let solve_ms = started.elapsed().as_millis() as u64; - use highs::{HighsModelStatus, HighsSolutionStatus}; - let status = match solved.status() { - HighsModelStatus::Optimal if only_read_back => SelectionMilpStatus::TimeLimit, - HighsModelStatus::Optimal => SelectionMilpStatus::Optimal, - HighsModelStatus::Infeasible | HighsModelStatus::UnboundedOrInfeasible => { - return Err(SelectionMilpError::Infeasible) + for (variable, coefficient) in objective_terms { + let column = column_of[&variable]; + models[part_of_column[column]] + .objective_terms + .push((rename(variable), coefficient)); } - HighsModelStatus::ReachedTimeLimit - | HighsModelStatus::ReachedInterrupt - | HighsModelStatus::ReachedIterationLimit - | HighsModelStatus::ReachedMemoryLimit - | HighsModelStatus::ReachedSolutionLimit => SelectionMilpStatus::TimeLimit, - other => return Err(SelectionMilpError::Solver(format!("{other:?}"))), + models }; - if solved.primal_solution_status() != HighsSolutionStatus::Feasible { - return Err(SelectionMilpError::NoSolution { - status: format!("{:?}", solved.status()), + // Smallest first: time a small part leaves goes to the larger ones. + let mut order: Vec = (0..models.len()).collect(); + order.sort_by_key(|&index| models[index].columns.len()); + let mut remaining_columns: usize = models.iter().map(|model| model.columns.len()).sum(); + let mut models: Vec> = models.into_iter().map(Some).collect(); + let mut columns = vec![0.0; column_count]; + let mut solved_parts: Vec> = (0..models.len()).map(|_| None).collect(); + let started = Instant::now(); + for index in order { + let model = models[index].take().expect("solved once"); + let share = model.columns.len().max(1) as f64 / remaining_columns.max(1) as f64; + remaining_columns -= model.columns.len(); + let part_deadline = deadline.map(|deadline| { + Instant::now() + + deadline + .saturating_duration_since(Instant::now()) + .mul_f64(share) + }); + let start = start_columns.as_ref().map(|start| { + model + .columns + .iter() + .map(|&column| start[column]) + .collect::>() }); + let part_columns = model.columns.clone(); + let solved = solve_part(model, start, part_deadline)?; + for (&column, &value) in part_columns.iter().zip(&solved.columns) { + columns[column] = value; + } + solved_parts[index] = Some(solved); } + let solve_ms = started.elapsed().as_millis() as u64; + let solved_parts: Vec = solved_parts + .into_iter() + .map(|part| part.expect("every part solved")) + .collect(); + let status = if solved_parts + .iter() + .all(|part| part.status == SelectionMilpStatus::Optimal) + { + SelectionMilpStatus::Optimal + } else { + SelectionMilpStatus::TimeLimit + }; + let highs_objective: f64 = solved_parts + .iter() + .map(|part| part.objective_per_second) + .sum(); + let dual_bound: f64 = solved_parts + .iter() + .map(|part| part.dual_bound_per_second) + .sum(); let relative_gap = match status { SelectionMilpStatus::Optimal => 0.0, - SelectionMilpStatus::TimeLimit => solved.mip_gap(), + SelectionMilpStatus::TimeLimit => { + (highs_objective - dual_bound) / highs_objective.abs().max(f64::MIN_POSITIVE) + } }; - let dual_bound = solved - .double_info_value(c"mip_dual_bound") - .unwrap_or(f64::NAN); - let solution = solved.get_solution(); - let columns = solution.columns(); // The objective of the integral plan: the incumbent's binaries, // rounded, with every continuous variable at its cheapest value (the // solver's own value carries its tolerances, relative to the scaled // objective). At the deadline, HiGHS's incumbent objective (an upper // bound) when the completion does not apply. - let objective_value = match completion.complete_columns(columns) { - Ok(completed) => completed.cost_per_second * scale, + let objective_value = match completion.complete_columns(&columns) { + Ok(completed) => completed.cost_per_second, Err(reason) => { warm_start::debug(|| format!("completion of the incumbent failed: {reason}")); - solved.objective_value() + highs_objective } }; warm_start::debug(|| { - format!( - "objective: HiGHS {:e}, completed {:e}", - solved.objective_value() / scale, - objective_value / scale - ) + format!("objective: HiGHS {highs_objective:e}, completed {objective_value:e}") }); + #[cfg(feature = "lp-diagnosis")] + if let Some((diagnosis, start_columns)) = diagnosis { + let incumbent = completion.complete_columns(&columns).ok(); + diagnosis.write( + start_columns.as_deref(), + incumbent + .as_ref() + .map(|completed| completed.columns.as_slice()), + objective_value, + dual_bound, + &format!("{status:?}"), + ); + } let on = |variable: Variable| columns[column_of[&variable]] > 0.5; Ok(SelectionMilpSolution { status, - cost_per_second: objective_value / scale, + cost_per_second: objective_value, request_cost_per_second, - cost_lower_bound_per_second: dual_bound / scale, + cost_lower_bound_per_second: dual_bound, relative_gap, chosen: choice .iter() @@ -1817,6 +2078,7 @@ pub(crate) fn solve_selection_milp( }) .collect(), size, + parts: solved_parts.into_iter().map(|part| part.report).collect(), build_ms, warm_start_ms, warm_start_cost_per_second, @@ -1824,8 +2086,120 @@ pub(crate) fn solve_selection_milp( }) } +/// Solves one part once with HiGHS, from `start` (its columns' values) when +/// given, with the time left before `deadline` as its time limit. +fn solve_part( + model: PartModel, + start: Option>, + deadline: Option, +) -> Result { + let PartModel { + variables, + rows, + objective_terms, + columns: part_columns, + queries, + } = model; + let start_values: Option> = start.as_ref().map(|start| { + variables + .iter_variables_with_def() + .map(|(variable, _)| variable) + .zip(start.iter().copied()) + .collect() + }); + let scale = objective_scale(&objective_terms, start_values.as_deref()); + let objective: Expression = objective_terms + .iter() + .map(|&(variable, coefficient)| coefficient * scale * variable) + .sum(); + let binaries = variables + .iter_variables_with_def() + .filter(|(_, definition)| definition.is_integer()) + .count(); + let row_count = rows.len(); + let problem = variables.minimise(objective).using(highs).with_all(rows); + let mut model = problem + .try_into_inner() + .map_err(|error| SelectionMilpError::Solver(error.to_string()))?; + model.set_option("mip_rel_gap", 0.0); + model.set_option("mip_abs_gap", 0.0); + model.set_option("output_flag", false); + // HiGHS's MIP search is mostly serial; a thread per core costs + // gigabytes of address space on a large host (#1036 under 8 GB). + model.set_option("threads", HIGHS_THREADS); + let started_with_plan = start.is_some(); + if let Some(start) = &start { + model + .try_set_solution(Some(start), None, None, None) + .map_err(|status| SelectionMilpError::Solver(format!("warm start: {status:?}")))?; + } + let mut only_read_back = false; + if let Some(deadline) = deadline { + let seconds = deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(); + if seconds <= 0.0 { + if !started_with_plan { + return Err(SelectionMilpError::DeadlineReached); + } + only_read_back = true; + } + model.set_option("time_limit", seconds.max(MOMENT_TO_READ_THE_START_S)); + } + let started = Instant::now(); + let solved = model + .try_solve() + .map_err(|status| SelectionMilpError::Solver(format!("{status:?}")))?; + let solve_ms = started.elapsed().as_millis() as u64; + use highs::{HighsModelStatus, HighsSolutionStatus}; + let status = match solved.status() { + HighsModelStatus::Optimal if only_read_back => SelectionMilpStatus::TimeLimit, + HighsModelStatus::Optimal => SelectionMilpStatus::Optimal, + HighsModelStatus::Infeasible | HighsModelStatus::UnboundedOrInfeasible => { + return Err(SelectionMilpError::Infeasible) + } + HighsModelStatus::ReachedTimeLimit + | HighsModelStatus::ReachedInterrupt + | HighsModelStatus::ReachedIterationLimit + | HighsModelStatus::ReachedMemoryLimit + | HighsModelStatus::ReachedSolutionLimit => SelectionMilpStatus::TimeLimit, + other => return Err(SelectionMilpError::Solver(format!("{other:?}"))), + }; + if solved.primal_solution_status() != HighsSolutionStatus::Feasible { + return Err(SelectionMilpError::NoSolution { + status: format!("{:?}", solved.status()), + }); + } + // HiGHS leaves `mip_dual_bound` at 0 for a part without binaries (an + // LP): its optimal objective is its bound. + let dual_bound = if binaries > 0 { + solved + .double_info_value(c"mip_dual_bound") + .unwrap_or(f64::NAN) + } else if status == SelectionMilpStatus::Optimal { + solved.objective_value() + } else { + f64::NAN + } / scale; + Ok(SolvedPart { + status, + columns: solved.get_solution().columns().to_vec(), + objective_per_second: solved.objective_value() / scale, + dual_bound_per_second: dual_bound, + report: PartSolve { + queries, + variables: part_columns.len(), + binaries, + rows: row_count, + status: format!("{status:?}"), + solve_ms, + dual_bound_per_second: dual_bound, + }, + }) +} + /// HiGHS's threads (its `threads` option). -const HIGHS_THREADS: i32 = 4; +pub(crate) const HIGHS_THREADS: i32 = 4; /// HiGHS's time limit when the warm start used up the time to the deadline. const MOMENT_TO_READ_THE_START_S: f64 = 1e-3; @@ -1836,7 +2210,7 @@ const WARM_START_MOST_TIME: std::time::Duration = std::time::Duration::from_secs /// The factor the objective is multiplied by for HiGHS: the warm start's /// cost becomes about 1,000, its largest coefficient at most 10⁷; without /// a warm start cost, the largest coefficient becomes 1,000. -fn objective_scale(terms: &[(Variable, f64)], start: Option<&[(Variable, f64)]>) -> f64 { +pub(crate) fn objective_scale(terms: &[(Variable, f64)], start: Option<&[(Variable, f64)]>) -> f64 { let largest = terms .iter() .map(|(_, coefficient)| coefficient.abs()) @@ -1856,5 +2230,17 @@ fn objective_scale(terms: &[(Variable, f64)], start: Option<&[(Variable, f64)]>) } } +/// Whether this thread builds the big-M bounds as before #1095 step 1 +/// (tests only: to compare the tightened bounds with the looser ones). +#[cfg(test)] +pub(crate) fn tests_loose_big_m() -> bool { + tests::LOOSE_BIG_M.with(|loose| loose.get()) +} + +#[cfg(not(test))] +pub(crate) fn tests_loose_big_m() -> bool { + false +} + #[cfg(test)] pub(crate) mod tests; diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs index 861a11a2e..ba87120eb 100644 --- a/crates/plan-selection/src/milp/model/tests.rs +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -64,7 +64,7 @@ pub(crate) fn through_stages(queries: &[Query<'_>], raw_data_retained: bool) -> /// [`through_stages`] over metric `m` declared non-negative (so relative /// errors compose through quotients, and Pass 1 keeps DDSketch). pub(crate) fn through_stages_non_negative(queries: &[Query<'_>]) -> Fixture { - through_stages_over(queries, true, false, true) + through_stages_over(queries, &["m"], true, false, true) } /// [`through_stages`], with rows arriving (100 per second) when @@ -74,12 +74,14 @@ pub(crate) fn through_stages_with( raw_data_retained: bool, ingesting: bool, ) -> Fixture { - through_stages_over(queries, raw_data_retained, ingesting, false) + through_stages_over(queries, &["m"], raw_data_retained, ingesting, false) } -/// The fixture builder behind [`through_stages`]. +/// The fixture builder behind [`through_stages`], over the metrics +/// `metrics`, each with 100 series sampled every second. fn through_stages_over( queries: &[Query<'_>], + metrics: &[&str], raw_data_retained: bool, ingesting: bool, values_non_negative: bool, @@ -115,9 +117,15 @@ fn through_stages_over( entry }) .collect(); - let mut data = DataWorkload::sampled_every(["m".to_string()], DurationMs(1_000)); - data.sources.get_mut("m").expect("m").series_count = Some(100); - data.sources.get_mut("m").expect("m").values_non_negative = values_non_negative; + let mut data = DataWorkload::sampled_every( + metrics.iter().map(|metric| metric.to_string()), + DurationMs(1_000), + ); + for metric in metrics { + let source = data.sources.get_mut(*metric).expect("declared"); + source.series_count = Some(100); + source.values_non_negative = values_non_negative; + } if ingesting { data.arrival = asap_types::workload::DataArrival::Ingesting { rows_per_second: 100.0, @@ -176,15 +184,17 @@ pub(crate) fn solve_and_check( cost_inputs: &CostInputs, restrict: impl FnOnce(&mut SelectionMilp<'_>), ) -> Result { - solve_and_check_by(fixture, cost_inputs, restrict, None) + solve_and_check_by(fixture, cost_inputs, restrict, None, true) } -/// [`solve_and_check`] with HiGHS's time limit `limit`. +/// [`solve_and_check`] with HiGHS's time limit `limit`, and the +/// independent parts solved apart when `split_into_parts`. fn solve_and_check_by( fixture: &Fixture, cost_inputs: &CostInputs, restrict: impl FnOnce(&mut SelectionMilp<'_>), limit: Option, + split_into_parts: bool, ) -> Result { let inputs = prepare_milp_inputs( &fixture.physical, @@ -199,6 +209,7 @@ fn solve_and_check_by( cost_inputs, SelectionMilpOptions { enforce_accuracy: true, + split_into_parts, }, )?; assert!(milp.inexact.is_empty(), "inexact terms: {:?}", milp.inexact); @@ -642,6 +653,7 @@ fn a_phantom_reader_does_not_satisfy_minimum_query_readers() { let inputs = prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); let options = SelectionMilpOptions { enforce_accuracy: true, + split_into_parts: true, }; let mut milp = build_selection_milp( &fixture.physical, @@ -775,6 +787,7 @@ fn cost_models_2_and_3_are_unsupported() { prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); let options = SelectionMilpOptions { enforce_accuracy: true, + split_into_parts: true, }; assert!(matches!( build_selection_milp( @@ -825,6 +838,7 @@ fn accuracy_rows_follow_the_composition_on_a_ratio() { &costs, SelectionMilpOptions { enforce_accuracy: true, + split_into_parts: true, }, ) .unwrap(); @@ -911,8 +925,14 @@ fn the_warm_start_is_a_feasible_plan_costed_exactly() { // time ends with a validated incumbent, reported as the deadline. #[test] fn a_deadline_yields_the_warm_starts_plan() { - let checked = - solve_and_check_by(&two_windows(), &cost_inputs(), |_| {}, Some(Duration::ZERO)).unwrap(); + let checked = solve_and_check_by( + &two_windows(), + &cost_inputs(), + |_| {}, + Some(Duration::ZERO), + true, + ) + .unwrap(); assert_eq!(checked.solution.status, SelectionMilpStatus::TimeLimit); let start = checked .solution @@ -956,3 +976,132 @@ fn a_widened_scan_reading_its_own_memo_group_plans_exactly() { assert_eq!(checked.solution.status, SelectionMilpStatus::Optimal); } } + +// #1095 §5 (3a): queries over two metrics share no candidate, run of `I` or +// source, so the model splits into one part per metric; solving the parts +// apart gives the plan and cost of one solve of the whole model. +#[test] +fn independent_query_sets_solve_apart_to_the_same_plan() { + let queries = [ + query("sum_over_time(m[5m])", 60_000), + query("sum_over_time(m[1h])", 60_000), + query("sum(n) + max(n)", 30_000), + ]; + let fixture = through_stages_over(&queries, &["m", "n"], true, false, false); + let costs = cost_inputs(); + let inputs = prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); + let options = SelectionMilpOptions { + enforce_accuracy: true, + split_into_parts: true, + }; + let milp = build_selection_milp( + &fixture.physical, + &inputs, + &fixture.entries, + &costs, + options, + ) + .unwrap(); + let parts = independent_parts(&milp); + let queries_per_part: Vec> = parts.iter().map(|part| part.queries.clone()).collect(); + assert_eq!(queries_per_part, vec![vec![0, 1], vec![2]]); + let columns: usize = parts.iter().map(|part| part.columns.len()).sum(); + let rows: usize = parts.iter().map(|part| part.rows.len()).sum(); + assert_eq!(columns, milp.builder.variables.len()); + assert_eq!(rows, milp.builder.rows.len()); + + let apart = solve_and_check_by(&fixture, &costs, |_| {}, None, true).unwrap(); + let whole = solve_and_check_by(&fixture, &costs, |_| {}, None, false).unwrap(); + assert_eq!(apart.solution.parts.len(), 2); + assert_eq!(whole.solution.parts.len(), 1); + assert_eq!(apart.solution.status, SelectionMilpStatus::Optimal); + assert_eq!(whole.solution.status, SelectionMilpStatus::Optimal); + let tolerance = 1e-9 * whole.solution.cost_per_second.abs(); + assert!( + (apart.solution.cost_per_second - whole.solution.cost_per_second).abs() <= tolerance, + "apart {:e} != whole {:e}", + apart.solution.cost_per_second, + whole.solution.cost_per_second + ); + assert_eq!(apart.dag.candidate_of_node, whole.dag.candidate_of_node); +} + +thread_local! { + /// Set by a test to build the big-M bounds as before #1095 step 1 + /// ([`super::tests_loose_big_m`]). + pub(crate) static LOOSE_BIG_M: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Per row of family `family` in `fixture`'s model (built with the loose +/// big-M bounds when `loose`), the largest coefficient on an `x` variable. +fn big_m_per_row(fixture: &Fixture, family: &str, loose: bool) -> Vec { + LOOSE_BIG_M.with(|cell| cell.set(loose)); + let costs = aws_fargate_cost_inputs(); + let inputs = prepare_milp_inputs(&fixture.physical, &fixture.entries, &fixture.data, &costs); + let milp = build_selection_milp( + &fixture.physical, + &inputs, + &fixture.entries, + &costs, + SelectionMilpOptions { + enforce_accuracy: true, + split_into_parts: true, + }, + ) + .unwrap(); + LOOSE_BIG_M.with(|cell| cell.set(false)); + let selected: std::collections::HashSet = milp.selected.values().copied().collect(); + milp.builder + .rows + .iter() + .zip(&milp.builder.row_families) + .filter(|(_, &f)| f == family) + .map(|(row, _)| { + row.expression() + .linear_coefficients() + .filter(|(variable, _)| selected.contains(variable)) + .map(|(_, coefficient)| coefficient.abs()) + .fold(0.0, f64::max) + }) + .collect() +} + +// #1095 step 1: `Ē[m]` in `V ≥ E − Ē (x[m] − z)` is the sum over the +// context's slots of its costliest run. With a rollup read every 20 s and +// every 30 s, a shared context has slots with several runs, so the bound is +// below the old sum over every run; the plan and its cost are unchanged. +#[test] +fn a_tighter_context_execution_bound_keeps_the_plan() { + let fixture = through_stages( + &[ + query("sum by (job) (sum_over_time(m[10m]))", 20_000), + query("sum(sum_over_time(m[10m]))", 30_000), + ], + true, + ); + let family = "V (context execution at a rate component)"; + let tight = big_m_per_row(&fixture, family, false); + let loose = big_m_per_row(&fixture, family, true); + assert_eq!(tight.len(), loose.len()); + assert!(tight + .iter() + .zip(&loose) + .all(|(t, l)| *t <= l * (1.0 + 1e-12))); + assert!(tight.iter().zip(&loose).any(|(t, l)| *t < l * (1.0 - 1e-9))); + + let tightened = solve_and_check(&fixture, &aws_fargate_cost_inputs(), |_| {}).unwrap(); + LOOSE_BIG_M.with(|cell| cell.set(true)); + let looser = solve_and_check(&fixture, &aws_fargate_cost_inputs(), |_| {}); + LOOSE_BIG_M.with(|cell| cell.set(false)); + let looser = looser.unwrap(); + assert_eq!(tightened.solution.status, SelectionMilpStatus::Optimal); + assert_eq!(looser.solution.status, SelectionMilpStatus::Optimal); + let tolerance = 1e-9 * looser.solution.cost_per_second.abs(); + assert!( + (tightened.solution.cost_per_second - looser.solution.cost_per_second).abs() <= tolerance + ); + assert_eq!( + tightened.dag.candidate_of_node, + looser.dag.candidate_of_node + ); +} diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs index 966be802d..877341c6b 100644 --- a/crates/plan-selection/src/select.rs +++ b/crates/plan-selection/src/select.rs @@ -98,7 +98,10 @@ pub(crate) fn select_plan_with_milp( &inputs, entries, cost_inputs, - SelectionMilpOptions { enforce_accuracy }, + SelectionMilpOptions { + enforce_accuracy, + split_into_parts: true, + }, ) .map_err(selection_error)?; let build_ms = construction_started.elapsed().as_millis() as u64; @@ -336,6 +339,7 @@ fn report_run( "plan_cost_per_second": plan_cost_per_second, "dual_bound_per_second": solution.cost_lower_bound_per_second, "relative_gap": solution.relative_gap, + "parts": solution.parts, }); use std::io::Write; if let Ok(mut file) = std::fs::OpenOptions::new() From 9f32a1f2ecd3426bc92c25ee90b31f85e614d2c6 Mon Sep 17 00:00:00 2001 From: Zeying Zhu <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:32:20 -0400 Subject: [PATCH 5/9] =?UTF-8?q?perf(plan-selection):=20keep=20accuracy=20b?= =?UTF-8?q?uilds=20across=20pair=20checks=20and=20queries=20(#1095=20?= =?UTF-8?q?=C2=A78)=20(#1156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured that candidates of one operator node never share an input context on the measured workloads (no same-timing pairs), so the §8 shared-input-context step has nothing to share. The 44 s build of rangeheavy-t8-t5 largest round was 96% accuracy checks; a candidate built outputs are now kept across pair checks and queries (except outputs built across a cycle of links), and duplicate exact stand-ins are skipped. Weights, excluded pairs, plans and costs unchanged; that round builds in 7.6 s. --- .../src/groups/accuracy_positions.rs | 275 ++++++++++++++++-- crates/plan-selection/src/groups/tests.rs | 116 +++++++- 2 files changed, 350 insertions(+), 41 deletions(-) diff --git a/crates/plan-selection/src/groups/accuracy_positions.rs b/crates/plan-selection/src/groups/accuracy_positions.rs index abf0b154e..710ea029e 100644 --- a/crates/plan-selection/src/groups/accuracy_positions.rs +++ b/crates/plan-selection/src/groups/accuracy_positions.rs @@ -17,7 +17,8 @@ //! fails through one reader is unusable through every reader (the largest //! weight counts, as before). The candidates of one operator node (other //! timings or materializations) build the same node, so they are checked -//! once, together. +//! once, together. What a check builds is kept for the later checks +//! ([`BuiltBefore`]). use std::cell::OnceCell; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -77,6 +78,7 @@ pub(crate) fn accuracy_positions( let exact_stand_ins: Vec>> = (0..physical.candidates.len()) .map(|_| OnceCell::new()) .collect(); + let mut built_before = BuiltBefore::default(); let mut positions = AccuracyPositions::default(); for (query, root) in physical.query_roots.iter().enumerate() { let references: Vec = (0..output_refs.len()) @@ -117,7 +119,7 @@ pub(crate) fn accuracy_positions( let mut weights = BTreeMap::new(); let mut usable: Vec<&[usize]> = Vec::new(); for group in groups.values() { - let weight = match checker.check(group) { + let weight = match checker.check(group, &mut built_before) { Outcome::Exact => continue, Outcome::Meets => { usable.push(group); @@ -165,7 +167,7 @@ pub(crate) fn accuracy_positions( .chain(usable[second]) .copied() .collect(); - if let Outcome::Fails(_) = checker.check(&both) { + if let Outcome::Fails(_) = checker.check(&both, &mut built_before) { for &one in usable[first] { for &other in usable[second] { excluded.push(( @@ -296,21 +298,82 @@ struct Checker<'a> { /// The outputs one candidate is built as, or why they are not checked. type Built = Result>, UnusableReason>; +/// What the checks built, kept across checks and queries. A candidate's +/// outputs depend only on the members of the approximate set at or below +/// it, so a pair check rebuilds only the candidates above both members +/// and reuses the rest from the single checks. Outputs built across a +/// cycle of links depend on where the walk entered the cycle and are not +/// kept. +#[derive(Default)] +struct BuiltBefore { + /// Per (candidate, approximate members at or below it, ascending): its + /// outputs. + outputs: HashMap<(usize, Vec), Built>, + /// The [`reader_key`]s of every node built so far. + keys: Keys, +} + +#[cfg(test)] +thread_local! { + /// Set by a test to check with no kept builds: every check builds + /// from scratch ([`BuiltBefore`] cleared) and builds every output + /// candidate (no exact stand-in skipped). + pub(crate) static FRESH_BUILDS: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// One check's walk. +#[derive(Default)] +struct Walk { + /// Per candidate built in this check. + built: HashMap, + /// The candidates being built. + visiting: Vec, + /// How many reads of a candidate still being built the walk has made. + cycle_reads: usize, +} + +/// A candidate's outputs in one check, and whether they were built across +/// a cycle of links (then they are not kept in [`BuiltBefore`]). +struct BuiltInWalk { + outputs: Built, + across_cycle: bool, +} + impl Checker<'_> { /// Checks the query's outputs with exactly `approximate` approximate. - fn check(&self, approximate: &[usize]) -> Outcome { - let mut memo: HashMap = HashMap::new(); - let mut keys = Keys::new(); + /// `before` holds what earlier checks built, for every query. + fn check(&self, approximate: &[usize], before: &mut BuiltBefore) -> Outcome { + #[cfg(test)] + let fresh = FRESH_BUILDS.with(std::cell::Cell::get); + #[cfg(not(test))] + let fresh = false; + if fresh { + *before = BuiltBefore::default(); + } + let mut walk = Walk::default(); let mut per_reference: Vec>> = Vec::new(); for &reference in self.references { let mut outputs: Vec> = Vec::new(); + // Exact stand-ins that are not summary states are the same + // for readers ([`reader_key`]): only the first is kept. + let mut exact_value_stand_in = false; for &class in &self.output_candidates[reference] { - match self.built(class.0, approximate, &mut memo, &mut keys, &mut Vec::new()) { + let reaches = approximate + .iter() + .any(|member| self.below[class.0].contains(member)); + if !fresh + && !reaches + && self.physical.physical_node(class).result_kind != OperatorResultKind::State + && std::mem::replace(&mut exact_value_stand_in, true) + { + continue; + } + match self.built(class.0, approximate, &mut walk, before) { Ok(built) => outputs.extend(built), Err(reason) => return Outcome::Fails(reason), } } - per_reference.push(distinct_for_readers(outputs, &mut keys)); + per_reference.push(distinct_for_readers(outputs, &mut before.keys)); } let Some(roots) = self.roots(&per_reference) else { return Outcome::Fails(UnusableReason::NotChecked); @@ -374,49 +437,69 @@ impl Checker<'_> { } /// The outputs `class` is built as with `approximate` approximate - /// (module documentation). `visiting` holds the candidates being built, - /// so a cycle of links builds nothing. + /// (module documentation). `walk.visiting` holds the candidates being + /// built, so a cycle of links builds nothing. fn built( &self, class: usize, approximate: &[usize], - memo: &mut HashMap, - keys: &mut Keys, - visiting: &mut Vec, + walk: &mut Walk, + before: &mut BuiltBefore, ) -> Built { - if let Some(done) = memo.get(&class) { - return done.clone(); + if let Some(done) = walk.built.get(&class) { + walk.cycle_reads += usize::from(done.across_cycle); + return done.outputs.clone(); } - if visiting.contains(&class) { + if walk.visiting.contains(&class) { + walk.cycle_reads += 1; return Ok(Vec::new()); } - let node = self.physical.physical_node(PhysicalCandidateId(class)); let reaches = |candidate: usize| { approximate .iter() .any(|member| self.below[candidate].contains(member)) }; + if !reaches(class) { + return Ok(vec![self.exact_stand_in(class)]); + } + let mut members: Vec = approximate + .iter() + .copied() + .filter(|member| self.below[class].contains(member)) + .collect(); + members.sort_unstable(); + let key = (class, members); + if let Some(done) = before.outputs.get(&key) { + walk.built.insert( + class, + BuiltInWalk { + outputs: done.clone(), + across_cycle: false, + }, + ); + return done.clone(); + } + let cycle_reads = walk.cycle_reads; + let node = self.physical.physical_node(PhysicalCandidateId(class)); let producers = &self.producer_candidates_per_input[class]; let below_inputs = producers .iter() .flatten() .any(|producer| reaches(producer.0)); - let built = if !reaches(class) { - Ok(vec![self.exact_stand_in(class)]) - } else if !below_inputs { + let built = if !below_inputs { // `class` is in `approximate`, over exact inputs: its node with // every guarantee below it composed, as the realizer does. with_guarantees(node, &mut HashMap::new()) .map(|node| vec![node]) .map_err(|_| UnusableReason::NotChecked) } else { - visiting.push(class); + walk.visiting.push(class); let mut per_input = Vec::with_capacity(producers.len()); let mut failed = None; for input in producers { let mut outputs = Vec::new(); for producer in input.iter().filter(|producer| reaches(producer.0)) { - match self.built(producer.0, approximate, memo, keys, visiting) { + match self.built(producer.0, approximate, walk, before) { Ok(built) => outputs.extend(built), Err(reason) => failed = Some(reason), } @@ -430,15 +513,25 @@ impl Checker<'_> { if let Some(exact) = exact { outputs.push(self.exact_stand_in(exact.0)); } - per_input.push(distinct_for_readers(outputs, keys)); + per_input.push(distinct_for_readers(outputs, &mut before.keys)); } - visiting.pop(); + walk.visiting.pop(); match failed { Some(reason) => Err(reason), - None => rebuilt(node, &per_input, keys), + None => rebuilt(node, &per_input, &mut before.keys), } }; - memo.insert(class, built.clone()); + let across_cycle = walk.cycle_reads != cycle_reads; + if !across_cycle { + before.outputs.insert(key, built.clone()); + } + walk.built.insert( + class, + BuiltInWalk { + outputs: built.clone(), + across_cycle, + }, + ); built } } @@ -565,3 +658,133 @@ fn key_and_settled(node: &Rc, keys: &mut Keys) -> (u64, bool) { keys.insert(Rc::as_ptr(node), (known.0, known.1, node.clone())); known } + +#[cfg(test)] +mod tests { + use super::*; + use crate::groups::tests::{candidate, catalog, reader_of, relative, root, values_node}; + use asap_logical_asap_optimization::test_support::entry_with; + use asap_types::ir::properties::AccuracyTarget; + use asap_types::workload::{QueryRecurrence, RepeatedDemand, RepetitionInterval}; + + /// The depth of each output of `built`. + fn depths(built: &Built) -> Vec { + fn depth(node: &OperatorNode) -> usize { + 1 + node + .children() + .into_iter() + .map(|child| depth(child)) + .max() + .unwrap_or(0) + } + built + .as_ref() + .expect("built") + .iter() + .map(|node| depth(node)) + .collect() + } + + // A two-candidate link cycle above an approximate member: `A` reads + // the leaf or `B`; `B` reads `A` or a second leaf. Walking from the + // top enters the cycle at `A`, so `B` is built there with `A` cut off + // (a cycle read), over the second leaf only. Such outputs are not + // kept: a later walk that starts at `B` builds it in full, over `A`, + // as with no kept builds, for one member and for a pair. + #[test] + fn outputs_built_across_a_cycle_are_not_kept() { + let approximate_leaf = |value: f64, error: f64| { + Rc::new( + (*values_node(value)) + .clone() + .with_guarantee(Some(relative(error, 0.01))), + ) + }; + let leaf = approximate_leaf(1.0, 0.01); + let other_leaf = approximate_leaf(2.0, 0.02); + let a = reader_of(&leaf); + let b = reader_of(&leaf); + let top = reader_of(&a); + let physical = catalog( + vec![(leaf, 0), (a, 1), (b, 2), (top, 3), (other_leaf, 4)], + vec![ + candidate(0, &[], false), + candidate(1, &[&[0, 2]], false), + candidate(2, &[&[1, 4]], false), + candidate(3, &[&[1]], true), + candidate(4, &[], false), + ], + vec![root(3)], + ); + let producers: Vec>> = physical + .candidates + .iter() + .map(|candidate| { + candidate + .producer_candidates_per_input + .iter() + .map(|producers| producers.iter().copied().collect()) + .collect() + }) + .collect(); + // Leaves 0 and 4 stand for approximate candidates. + let below: Vec> = vec![ + BTreeSet::from([0]), + BTreeSet::from([0, 4]), + BTreeSet::from([0, 4]), + BTreeSet::from([0, 4]), + BTreeSet::from([4]), + ]; + let exact_stand_ins: Vec>> = (0..physical.candidates.len()) + .map(|_| OnceCell::new()) + .collect(); + let entry = entry_with( + AccuracyTarget::max_error(0.01).failure_probability(0.01), + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))), + ); + let data = DataWorkload::default(); + let cost_inputs = asap_cost::CostInputs::default(); + let models = SelectionModels { + accuracy: &asap_accuracy::BuiltinAccuracyModel, + evidence: &asap_accuracy::NoAccuracyEvidence, + cost_inputs: &cost_inputs, + }; + let output_candidates = vec![vec![PhysicalCandidateId(3)]]; + let checker = Checker { + physical: &physical, + exact_stand_ins: &exact_stand_ins, + producer_candidates_per_input: &producers, + output_candidates: &output_candidates, + below: &below, + query: 0, + root: &physical.query_roots[0], + references: &[0], + entry: &entry, + data: &data, + models: &models, + }; + for approximate in [&[0][..], &[0, 4]] { + let mut kept = BuiltBefore::default(); + // From the top: `B` is built with `A` cut off, over the second + // leaf alone (depth 2). + let mut walk = Walk::default(); + checker + .built(3, approximate, &mut walk, &mut kept) + .expect("built"); + assert!(walk.cycle_reads > 0); + assert!(walk.built[&2].across_cycle); + assert_eq!(depths(&walk.built[&2].outputs)[0], 2, "{approximate:?}"); + // From `B`: in full, its first output reads `A` over the leaf + // (depth 3), with kept builds as with none. + let from_b = checker.built(2, approximate, &mut Walk::default(), &mut kept); + let fresh = checker.built( + 2, + approximate, + &mut Walk::default(), + &mut BuiltBefore::default(), + ); + assert_eq!(depths(&from_b), depths(&fresh), "{approximate:?}"); + assert_eq!(depths(&fresh)[0], 3, "{approximate:?}"); + } + } +} diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index 80c9c2574..058fce70f 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -18,7 +18,7 @@ use asap_types::stages::{ // --------------------------------------------------------------------------- /// A one-row `VALUES` node of `value`. -fn values_node(value: f64) -> Rc { +pub(super) fn values_node(value: f64) -> Rc { OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Values { rows: vec![vec![ScalarExpr::Literal(ScalarValue::Float64(value))]], schema: Schema::lifted(vec![Field::plain("value", DataType::Float64, false)], None), @@ -37,7 +37,7 @@ fn rows_node(rows: usize) -> Rc { } /// A stateless one-input reader of `child`. -fn reader_of(child: &Rc) -> Rc { +pub(super) fn reader_of(child: &Rc) -> Rc { OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Dedup { cols: Vec::new(), child: Rc::clone(child), @@ -48,7 +48,7 @@ fn reader_of(child: &Rc) -> Rc { /// A catalog of `nodes`, node `i` in memo group `nodes[i].1` (each node its /// own candidate of that group, all implementation roots), with /// `candidates` and one query root per entry of `roots`. -fn catalog( +pub(super) fn catalog( nodes: Vec<(Rc, u32)>, candidates: Vec, roots: Vec>, @@ -80,7 +80,7 @@ fn catalog( /// One candidate of operator node `node` at query time, not materialized, /// reading `producers` per input, that may produce a query's output when /// `output`. -fn candidate(node: usize, producers: &[&[usize]], output: bool) -> PhysicalCandidate { +pub(super) fn candidate(node: usize, producers: &[&[usize]], output: bool) -> PhysicalCandidate { PhysicalCandidate { operator_node: OperatorNodeOfCandidateForMemoGroupId(node), timing: ExecutionTiming::QueryTime, @@ -99,7 +99,7 @@ fn candidate(node: usize, producers: &[&[usize]], output: bool) -> PhysicalCandi } } -fn root(group: u32) -> QueryRoot { +pub(super) fn root(group: u32) -> QueryRoot { QueryRoot::Operator(MemoGroupId(group)) } @@ -148,7 +148,7 @@ fn data_with(usage: CandidateUsage) -> CandidateData { } } -fn relative(error: f64, failure_probability: f64) -> AccuracyGuarantee { +pub(super) fn relative(error: f64, failure_probability: f64) -> AccuracyGuarantee { AccuracyGuarantee::new( AccuracyMetric::Relative, BoundExpr::Constant { value: error }, @@ -553,21 +553,31 @@ fn through_stages_1_and_2(queries: &[&str]) -> ThroughStages1And2 { /// [`through_stages_1_and_2`] over the pane-width domain `domain`. fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> ThroughStages1And2 { + use asap_logical_asap_optimization::test_support::lower_promql; + let roots: Vec = queries + .iter() + .map(|query| QueryRoot::Operator(lower_promql(query, target()))) + .collect(); + through_stages_1_and_2_of(roots, domain) +} + +/// The accuracy target of [`through_stages_1_and_2`]'s queries. +fn target() -> asap_types::ir::properties::AccuracyTarget { + asap_types::ir::properties::AccuracyTarget::max_error(0.01).failure_probability(0.01) +} + +/// [`through_stages_1_and_2_over`] for query roots `roots`. +fn through_stages_1_and_2_of(roots: Vec, domain: PaneWidthDomain) -> ThroughStages1And2 { use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; - use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_logical_asap_optimization::test_support::entry_with; use asap_logical_asap_optimization::{generate_logical_asap_memo_candidates, Pass2RoundState}; - use asap_types::ir::properties::AccuracyTarget; use asap_types::workload::{ DurationMs, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, }; - let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let roots: Vec = queries - .iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); + let target = target(); let every_minute = QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))); - let entries: Vec<_> = queries + let entries: Vec<_> = roots .iter() .map(|_| QueryWorkloadEntry { predictability: Predictability::Predictable { known_at: None }, @@ -578,7 +588,7 @@ fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> Thr let source = data.sources.get_mut("m").expect("m"); source.series_count = Some(100); source.values_non_negative = true; - let targets = vec![target.clone(); queries.len()]; + let targets = vec![target.clone(); roots.len()]; let pass1 = generate_per_computation_logical_memo_candidates( asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&roots), &targets, @@ -823,3 +833,79 @@ fn one_pane_chain_serves_a_ratio_and_its_operands() { ); } } + +// The checks keep what they build for later checks and skip repeated +// exact stand-ins: on RangeHeavy T8+T5's shape (a quotient and both +// operands alone), on a quotient of three quantiles (where pairs are +// excluded) and on a ratio with one approximate operand, the weights and +// excluded pairs equal those with no kept builds and no skipped stand-in. +#[test] +fn kept_builds_give_the_weights_of_fresh_builds() { + kept_builds_match_fresh_builds(&[ + "quantile_over_time(0.9, m[5m]) / quantile_over_time(0.5, m[5m])", + "quantile_over_time(0.9, m[5m])", + "quantile_over_time(0.5, m[5m])", + ]); + let three = kept_builds_match_fresh_builds(&[ + "quantile_over_time(0.9, m[5m]) / quantile_over_time(0.5, m[5m]) / quantile_over_time(0.1, m[5m])", + ]); + assert!(!three.accuracy_excluded_pairs[0].is_empty()); + // A scalar ratio of two output references whose approximate values sit + // under one operand only: the other reference's output candidates + // reach no member, and its first exact stand-in must still be built, + // or the ratio has no root to check and every approximate candidate + // would weigh nothing. + use asap_logical_asap_optimization::test_support::lower_promql; + use asap_types::ir::scalar::{ArithmeticOpKind, ExprSemantics}; + let operand = |query: &str| { + Box::new(ScalarExpr::PromqlScalarFromVector(lower_promql( + query, + target(), + ))) + }; + let ratio = QueryRoot::Scalar(ScalarExpr::Arithmetic { + op: ArithmeticOpKind::Div, + left: operand("avg_over_time(m[5m])"), + right: operand("quantile_over_time(0.5, m[5m])"), + semantics: ExprSemantics::Promql, + }); + let ratio = kept_builds_match_fresh_builds_of(vec![ratio]); + assert!(!ratio.accuracy_weights[0].is_empty()); +} + +/// Asserts that `queries`' accuracy weights and excluded pairs are the +/// same with kept builds and with none ([`accuracy_positions::FRESH_BUILDS`]); +/// returns the former. +fn kept_builds_match_fresh_builds(queries: &[&str]) -> MilpInputs { + use asap_logical_asap_optimization::test_support::lower_promql; + kept_builds_match_fresh_builds_of( + queries + .iter() + .map(|query| QueryRoot::Operator(lower_promql(query, target()))) + .collect(), + ) +} + +/// [`kept_builds_match_fresh_builds`] for query roots `roots`. +fn kept_builds_match_fresh_builds_of(roots: Vec) -> MilpInputs { + let stages = through_stages_1_and_2_of(roots, PaneWidthDomain::SeedGrids); + let prepare = |fresh: bool| { + accuracy_positions::FRESH_BUILDS.with(|cell| cell.set(fresh)); + let inputs = prepare_milp_inputs( + &stages.physical, + &stages.entries, + &stages.data, + &CostInputs::default(), + ); + accuracy_positions::FRESH_BUILDS.with(|cell| cell.set(false)); + inputs + }; + let (kept, fresh) = (prepare(false), prepare(true)); + assert!(kept + .accuracy_weights + .iter() + .any(|weights| !weights.is_empty())); + assert_eq!(kept.accuracy_weights, fresh.accuracy_weights); + assert_eq!(kept.accuracy_excluded_pairs, fresh.accuracy_excluded_pairs); + kept +} From 73072e68228e898ff779a0df0dd1787ebb4bbd76 Mon Sep 17 00:00:00 2001 From: Zeying Zhu <50204836+zzylol@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:21:55 -0400 Subject: [PATCH 6/9] feat(plan-selection): one ingestion context per refresh window in the selection MILP (#1095) (#1157) Brings feat/1095-stage3 (#1151 refresh groups and cost rule) into the prototype and drops its once-per-second ingestion hook. The MILP has one ingestion context I_r per refresh window (unbounded views alone), so a shared stateless ingestion node is charged once per refresh window, as asap-cost charges it. workload-01 costs 1.6% more: its old plan under-charged a scan read by chains of three refresh windows. Part of #1095. --- crates/cost/src/execution_demand.rs | 14 +- crates/plan-selection/src/groups.rs | 271 +++++++++++++++--- crates/plan-selection/src/groups/tests.rs | 198 ++++++++++++- crates/plan-selection/src/milp/cost_terms.rs | 11 +- crates/plan-selection/src/milp/decode.rs | 12 +- .../plan-selection/src/milp/lp_diagnosis.rs | 7 +- crates/plan-selection/src/milp/model.rs | 126 ++++---- crates/plan-selection/src/milp/model/tests.rs | 179 +++++++++++- crates/plan-selection/src/milp/warm_start.rs | 22 +- .../src/search/costing/direct_profile.rs | 7 +- crates/types/src/ir/physical_export.rs | 14 +- crates/types/tests/materialized_view.rs | 29 +- 12 files changed, 732 insertions(+), 158 deletions(-) diff --git a/crates/cost/src/execution_demand.rs b/crates/cost/src/execution_demand.rs index e39d5028f..6a7e380bd 100644 --- a/crates/cost/src/execution_demand.rs +++ b/crates/cost/src/execution_demand.rs @@ -9,7 +9,6 @@ use super::analytical::AnalyticalCostError; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPNodeId}; -use asap_types::ir::properties::ExecutionTiming; use asap_types::physical::Materialization; use asap_types::workload::{QueryRecurrence, RangeEvaluation, RepeatedDemand}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -346,9 +345,11 @@ pub fn consumers(dag: &PhysicalASAPDAG) -> HashMap (1, rate), - None if n.output_state.timing == ExecutionTiming::IngestionTime - && count.contains_key(&n.id) => - { - (1, 1.0) - } None => count.get(&n.id).copied().unwrap_or((0, 0.0)), }; (n.id, times) diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs index 997e6bed3..7c6bcd4c6 100644 --- a/crates/plan-selection/src/groups.rs +++ b/crates/plan-selection/src/groups.rs @@ -106,6 +106,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use asap_cost::{CostInputs, NodeUsage, ProducerOutput}; +use asap_types::ir::materialized_view::RefreshWindow; use asap_types::ir::physical_export::{ PhysicalASAPDAG, PhysicalASAPDAGNode, PhysicalASAPNodeId, ScanWindow, }; @@ -147,11 +148,15 @@ pub(crate) struct MilpInputs { /// Indexed by [`PhysicalCandidateId`]: the result it gives. pub(crate) slot: Vec, /// The execution contexts: one per query (`0..Q`, in workload order), - /// then the ingestion pipeline `I` (index `Q`), then one per shareable - /// candidate some context may read, ascending by candidate. + /// then the ingestion pipelines `I_r` some context reads, ascending by + /// [`IngestionPipeline`], then one per shareable candidate some context + /// may read, ascending by candidate. pub(crate) contexts: Vec, /// Per shareable candidate with a context, its index in `contexts`. pub(crate) context_of_shareable: BTreeMap, + /// Per ingestion-time shareable candidate whose context reads a + /// candidate of an `I_r`: the index of that `I_r` in `contexts`. + pub(crate) ingestion_context_of_shareable: BTreeMap, /// Per query: `w(q, k)` for each approximate candidate `k` whose values /// reach its output. pub(crate) accuracy_weights: Vec>, @@ -341,7 +346,8 @@ pub(crate) enum RunKind { /// inputs) that selects it. PerContext, /// `NotMaterialized` at ingestion time: runs in the ingestion pipeline - /// `I`, once per second of arriving data for every reader. + /// `I_r` of its readers' refresh window `r`, once per second of + /// arriving data for all of them. Ingestion, /// Materialized (`MatAtIngestion`, `MatAtQuery`, /// `MatAtQueryKeptAcrossEvaluations`): selected once, its own execution @@ -531,15 +537,18 @@ pub(crate) fn prepare_milp_inputs_with( let unpruned_links = producer_candidates_left(links, &all_alive); let unpruned_outputs = output_candidates(physical, &all_alive); let unpruned_reach = queries_reaching(physical, &unpruned_links, &unpruned_outputs); + let refresh_windows = refresh_windows(physical, links); // Every context before pruning, for the windows each scan may be read // at within one context. - let (all_contexts, _) = execution_contexts( + let all_contexts = execution_contexts( physical, &links.kind, &unpruned_links, &unpruned_outputs, + &refresh_windows, |_| true, - ); + ) + .contexts; let candidate_data = candidate_data( physical, links, @@ -563,11 +572,16 @@ pub(crate) fn prepare_milp_inputs_with( let kind = links.kind.clone(); let slot = result_slots(physical); let usable = |class: PhysicalCandidateId| candidate_data[class.0].usage.is_ok(); - let (contexts, context_of_shareable) = execution_contexts( + let ExecutionContexts { + contexts, + context_of_shareable, + ingestion_context_of_shareable, + } = execution_contexts( physical, &kind, &producer_candidates_per_input, &output_candidates, + &refresh_windows, usable, ); let AccuracyPositions { @@ -591,6 +605,7 @@ pub(crate) fn prepare_milp_inputs_with( slot, contexts, context_of_shareable, + ingestion_context_of_shareable, accuracy_weights, accuracy_excluded_pairs, } @@ -672,15 +687,124 @@ fn queries_reaching( reaching } -/// Which execution context (#1095 §2.0): a query evaluation, the -/// ingestion pipeline `I`, or a shareable candidate's own inputs. +/// Which execution context (#1095 §2.0): a query evaluation, an ingestion +/// pipeline `I_r`, or a shareable candidate's own inputs. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub(crate) enum ContextKind { Query(usize), - Ingestion, + Ingestion(IngestionPipeline), Shared(PhysicalCandidateId), } +/// Which ingestion pipeline `I_r` (#1095 §2.5 O3(b)): the deployment +/// refreshes the ingestion-time views of one refresh window in one run, so +/// `asap_cost::execution_demand::computations` charges a `NotMaterialized` +/// ingestion-time node once per refresh window of the views reading it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum IngestionPipeline { + /// The views of this refresh window. + Window(RefreshWindow), + /// An ingestion-time shareable candidate whose window is unbounded or + /// empty: refreshed alone. + Alone(PhysicalCandidateId), +} + +/// The [`IngestionPipeline`] of ingestion-time shareable candidate +/// `shareable`, from its refresh window ([`refresh_windows`]). +pub(crate) fn ingestion_pipeline( + shareable: PhysicalCandidateId, + refresh_windows: &HashMap>, +) -> IngestionPipeline { + match refresh_windows.get(&shareable) { + Some(Some(window)) => IngestionPipeline::Window(*window), + _ => IngestionPipeline::Alone(shareable), + } +} + +/// Each ingestion-time shareable candidate's refresh window, as +/// `asap_cost::execution_demand::computations` computes it on the realized +/// plan ([`PhysicalASAPDAG::refresh_windows`]): on the DAG of the candidates +/// below it, each input read from its first producer candidate. That is +/// exact when the producer candidates of each input read one window, which +/// is debug-asserted ([`producer_window_disagreements`]). +fn refresh_windows( + physical: &PhysicalASAPMemoCandidates, + links: &CandidateLinks, +) -> HashMap> { + let views: Vec = (0..physical.candidates.len()) + .map(PhysicalCandidateId) + .filter(|&class| { + links.is_shareable(class) + && physical.candidates[class.0].timing == ExecutionTiming::IngestionTime + }) + .collect(); + let dag = dag_below(physical, links, &views); + debug_assert!( + producer_window_disagreements(links, &dag).is_empty(), + "producer candidates of one input read different windows: {:?}", + producer_window_disagreements(links, &dag) + ); + dag.refresh_windows(views.iter().map(|view| view.0)) + .into_iter() + .map(|(id, window)| (PhysicalCandidateId(id), window)) + .collect() +} + +/// The DAG of `tops` and every candidate below them, without edges: each +/// candidate a node ([`catalog_node`]) whose inputs are its first producer +/// candidates. +fn dag_below( + physical: &PhysicalASAPMemoCandidates, + links: &CandidateLinks, + tops: &[PhysicalCandidateId], +) -> PhysicalASAPDAG { + let mut nodes = Vec::new(); + let mut seen = HashSet::new(); + let mut stack = tops.to_vec(); + while let Some(class) = stack.pop() { + if seen.insert(class) { + nodes.push(catalog_node(physical, links, class)); + stack.extend(links.producers_per_input[class.0].iter().flatten()); + } + } + let nodes: Vec<&PhysicalASAPDAGNode> = nodes.iter().collect(); + nodes_dag(&nodes) +} + +/// The `(candidate, input)` pairs of `dag`'s candidates +/// ([`dag_below`]) whose producer candidates have different view windows +/// ([`PhysicalASAPDAG::view_windows`]). +fn producer_window_disagreements( + links: &CandidateLinks, + dag: &PhysicalASAPDAG, +) -> Vec<(PhysicalCandidateId, usize)> { + let windows = dag.view_windows(dag.nodes.iter().map(|node| node.id)); + let mut disagreements = Vec::new(); + for node in &dag.nodes { + for (input, producers) in links.producers_per_input[node.id].iter().enumerate() { + let mut read = producers.iter().map(|producer| windows[&producer.0]); + if let Some(first) = read.next() { + if read.any(|window| window != first) { + disagreements.push((PhysicalCandidateId(node.id), input)); + } + } + } + } + disagreements +} + +/// [`producer_window_disagreements`] over every candidate of `physical`. +#[cfg(test)] +pub(crate) fn producer_window_disagreements_of_all( + physical: &PhysicalASAPMemoCandidates, +) -> Vec<(PhysicalCandidateId, usize)> { + let links = CandidateLinks::new(physical); + let all: Vec = (0..physical.candidates.len()) + .map(PhysicalCandidateId) + .collect(); + producer_window_disagreements(&links, &dag_below(physical, &links, &all)) +} + /// One execution context and the candidates that may give a result in it. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ExecutionContext { @@ -690,7 +814,7 @@ pub(crate) struct ExecutionContext { pub(crate) at_query_time: bool, /// The candidates that may give a result slot in it, ascending: each /// computed in it ([`ExecutionContext::computes`]) or read across its - /// boundary (a shareable candidate, or a candidate of `I`). + /// boundary (a shareable candidate, or a candidate of an `I_r`). pub(crate) options: Vec, } @@ -700,15 +824,37 @@ impl ExecutionContext { pub(crate) fn computes(&self, kind: RunKind) -> bool { match kind { RunKind::PerContext => self.at_query_time, - RunKind::Ingestion => self.kind == ContextKind::Ingestion, + RunKind::Ingestion => matches!(self.kind, ContextKind::Ingestion(_)), RunKind::Shareable => false, } } } +impl MilpInputs { + /// The context where context `context` reads a candidate of an `I_r`: + /// itself for an `I_r`, `I_{r(m)}` for ingestion-time shareable `m`'s + /// context; `None` for any other context (it reads none). + pub(crate) fn ingestion_context_read_by(&self, context: usize) -> Option { + match self.contexts[context].kind { + ContextKind::Ingestion(_) => Some(context), + ContextKind::Shared(m) => self.ingestion_context_of_shareable.get(&m).copied(), + ContextKind::Query(_) => None, + } + } +} + +/// [`execution_contexts`]' result (see [`MilpInputs`] for each field). +pub(crate) struct ExecutionContexts { + pub(crate) contexts: Vec, + pub(crate) context_of_shareable: BTreeMap, + pub(crate) ingestion_context_of_shareable: BTreeMap, +} + /// The execution contexts and their options (module documentation): from /// each query's output candidates, each shareable candidate's producers -/// and every candidate of `I` read across a boundary, expanded through the +/// and, per ingestion pipeline `I_r`, every candidate of `I_r` an +/// ingestion-time shareable candidate of refresh window `r` +/// (`refresh_windows`) reads across its boundary, expanded through the /// inputs of the candidates each context computes. Candidates `usable` /// rejects (no usage) are left out. A query-time `PerContext` candidate /// that only an ingestion-time context could read is left out too: it @@ -718,8 +864,9 @@ pub(crate) fn execution_contexts( kind: &[RunKind], producer_candidates_per_input: &[Vec>], output_candidates: &[Vec], + refresh_windows: &HashMap>, usable: impl Fn(PhysicalCandidateId) -> bool, -) -> (Vec, BTreeMap) { +) -> ExecutionContexts { let query_count = physical.query_roots.len(); let output_refs = output_references(&physical.query_roots); // Expands `seeds` in a context: the options and, through the candidates @@ -763,48 +910,62 @@ pub(crate) fn execution_contexts( context.options = expand(&context, seeds); contexts.push(context); } - // Shareable contexts, discovered from the options, then `I` from every - // candidate of `I` read across a boundary; repeated until nothing new. + // Shareable contexts, discovered from the options, then each `I_r` from + // every candidate of `I_r` read across a boundary; repeated until + // nothing new. let mut context_of_shareable: BTreeMap = BTreeMap::new(); let mut shared: Vec = Vec::new(); - let mut ingestion = ExecutionContext { - kind: ContextKind::Ingestion, - at_query_time: false, - options: Vec::new(), - }; - let mut ingestion_seeds: BTreeSet = BTreeSet::new(); + // Per `I_r`: its context and its seeds. + let mut ingestion: BTreeMap< + IngestionPipeline, + (ExecutionContext, BTreeSet), + > = BTreeMap::new(); let mut scanned = 0; loop { let mut pending: Vec = Vec::new(); - let mut new_ingestion = false; - { - let all = contexts - .iter() - .chain(shared.iter()) - .chain(std::iter::once(&ingestion)); - for (index, context) in all.enumerate() { - if index < scanned && context.kind != ContextKind::Ingestion { - continue; + let mut seeds: Vec<(IngestionPipeline, PhysicalCandidateId)> = Vec::new(); + let unscanned = contexts.iter().chain(shared.iter()).skip(scanned); + for context in unscanned.chain(ingestion.values().map(|(context, _)| context)) { + // Only an ingestion-time shareable candidate reads a candidate + // of an `I_r` (Stage 2's timing rule). + let pipeline = match context.kind { + ContextKind::Shared(m) if !context.at_query_time => { + Some(ingestion_pipeline(m, refresh_windows)) } - for &class in &context.options { - match kind[class.0] { - RunKind::Shareable => { - if !context_of_shareable.contains_key(&class) - && !pending.contains(&class) - { - pending.push(class); - } + _ => None, + }; + for &class in &context.options { + match kind[class.0] { + RunKind::Shareable => { + if !context_of_shareable.contains_key(&class) && !pending.contains(&class) { + pending.push(class); } - RunKind::Ingestion if context.kind != ContextKind::Ingestion => { - new_ingestion |= ingestion_seeds.insert(class); + } + RunKind::Ingestion => { + if let Some(pipeline) = pipeline { + seeds.push((pipeline, class)); } - _ => {} } + RunKind::PerContext => {} } } } scanned = contexts.len() + shared.len(); - if pending.is_empty() && !new_ingestion { + let mut grown: BTreeSet = BTreeSet::new(); + for (pipeline, class) in seeds { + let (_, known) = ingestion.entry(pipeline).or_insert_with(|| { + let context = ExecutionContext { + kind: ContextKind::Ingestion(pipeline), + at_query_time: false, + options: Vec::new(), + }; + (context, BTreeSet::new()) + }); + if known.insert(class) { + grown.insert(pipeline); + } + } + if pending.is_empty() && grown.is_empty() { break; } pending.sort_unstable(); @@ -826,19 +987,35 @@ pub(crate) fn execution_contexts( context_of_shareable.insert(class, usize::MAX); shared.push(context); } - if new_ingestion { - ingestion.options = expand(&ingestion, ingestion_seeds.iter().copied().collect()); + for pipeline in grown { + let (context, known) = ingestion.get_mut(&pipeline).expect("seeded above"); + context.options = expand(context, known.iter().copied().collect()); } } shared.sort_by_key(|context| context.kind); - contexts.push(ingestion); + let mut context_of_pipeline: BTreeMap = BTreeMap::new(); + for (pipeline, (context, _)) in ingestion { + context_of_pipeline.insert(pipeline, contexts.len()); + contexts.push(context); + } + let mut ingestion_context_of_shareable = BTreeMap::new(); for context in shared { if let ContextKind::Shared(class) = context.kind { context_of_shareable.insert(class, contexts.len()); + if !context.at_query_time { + let pipeline = ingestion_pipeline(class, refresh_windows); + if let Some(&at) = context_of_pipeline.get(&pipeline) { + ingestion_context_of_shareable.insert(class, at); + } + } } contexts.push(context); } - (contexts, context_of_shareable) + ExecutionContexts { + contexts, + context_of_shareable, + ingestion_context_of_shareable, + } } // --------------------------------------------------------------------------- @@ -1015,7 +1192,7 @@ fn scan_window_options( } let read_whole = match context.kind { ContextKind::Query(_) => output_candidates.iter().any(|out| out.contains(&scan)), - ContextKind::Shared(_) | ContextKind::Ingestion => false, + ContextKind::Shared(_) | ContextKind::Ingestion(_) => false, }; if read_whole && !kinds.contains(&ScanWindow::Unbounded) { kinds.push(ScanWindow::Unbounded); diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index 058fce70f..be102c451 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -503,7 +503,7 @@ fn shared_middle() -> PhysicalASAPMemoCandidates { // Each query context holds its own top and the shared middle as a read // across its boundary; the middle's own context holds the leaf below it; -// `I` holds nothing; a query that cannot reach the middle holds only its +// no `I_r` is read, so there is none; a query that cannot reach the middle holds only its // own candidate. #[test] fn execution_contexts_on_a_hand_built_catalog() { @@ -523,13 +523,12 @@ fn execution_contexts_on_a_hand_built_catalog() { (ContextKind::Query(0), vec![middle, top_0]), (ContextKind::Query(1), vec![middle, top_1]), (ContextKind::Query(2), vec![lone]), - (ContextKind::Ingestion, Vec::new()), (ContextKind::Shared(middle), vec![leaf]), ] ); - assert_eq!(inputs.context_of_shareable, BTreeMap::from([(middle, 4)])); - assert!(inputs.contexts[4].computes(RunKind::PerContext)); - assert!(!inputs.contexts[4].computes(RunKind::Shareable)); + assert_eq!(inputs.context_of_shareable, BTreeMap::from([(middle, 3)])); + assert!(inputs.contexts[3].computes(RunKind::PerContext)); + assert!(!inputs.contexts[3].computes(RunKind::Shareable)); assert_eq!( inputs.output_candidates, vec![vec![top_0], vec![top_1], vec![lone]] @@ -666,7 +665,8 @@ fn two_queries_share_a_pane_chain_through_the_real_pipeline() { assert!( context.computes(kind) || kind == RunKind::Shareable - || (kind == RunKind::Ingestion && context.kind != ContextKind::Ingestion) + || (kind == RunKind::Ingestion + && !matches!(context.kind, ContextKind::Ingestion(_))) ); } } @@ -909,3 +909,189 @@ fn kept_builds_match_fresh_builds_of(roots: Vec) -> MilpInputs { assert_eq!(kept.accuracy_excluded_pairs, fresh.accuracy_excluded_pairs); kept } + +// --------------------------------------------------------------------------- +// Ingestion pipelines per refresh window +// --------------------------------------------------------------------------- + +/// Ingestion-time views of metric `m`, each read by its own query: +/// - candidates 2 and 3: a KLL and a DDSketch over `TimeRange(10 s)` (one +/// refresh window, (10 s, 0)); +/// - 5: a KLL over `TimeRange(60 s)`, window (60 s, 0); +/// - 8: a KLL over `TimeRange(10 s)` over `TimeShift(5 s)`, window +/// (10 s, 5 s), through two `NotMaterialized` ingestion-time nodes; +/// - 9: a KLL over the whole scan, an unbounded window. +/// +/// Candidates 0, 1, 4, 6 and 7 (the scan, ranges and shift) are +/// `NotMaterialized` at ingestion time; 10–14 are the query-time readers. +fn ingestion_views_of_three_windows_and_an_unbounded_one() -> PhysicalASAPMemoCandidates { + use asap_types::ir::operator::{Reduction, Source, TimeShift}; + use asap_types::ir::scalar::ColumnRef; + use asap_types::ir::schema::{GroupingStrategy, SketchKind, SketchParams, SummaryUpdate}; + use asap_types::ir::TimeRangeKind; + use std::time::Duration; + let scan = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + Field::plain("job", DataType::Utf8, true), + ], + 0, + vec![], + ), + })) + .unwrap(); + let range = |seconds: u64, child: &Rc| { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(seconds), + kind: TimeRangeKind::Range, + child: Rc::clone(child), + })) + .unwrap() + }; + let sketch = |algorithm: SketchAlgorithm, child: &Rc| { + let params = match algorithm { + SketchAlgorithm::Kll => SketchParams::Kll { + k: 200, + failure_probability: None, + }, + _ => SketchParams::DDSketch { alpha: 0.01 }, + }; + OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(child), + family: FieldDataType::Sketch(SketchKind::new(algorithm, params), Default::default()), + input: SummaryUpdate::column(ColumnRef::Named("value".into())), + reduction: Reduction::by(vec![2]), + grouping: GroupingStrategy::default(), + filter: None, + })) + .unwrap() + }; + let range_10s = range(10, &scan); + let range_60s = range(60, &scan); + let shifted = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::TimeShift { + shift: TimeShift { + offset_ms: 5_000, + at: None, + }, + child: Rc::clone(&scan), + })) + .unwrap(); + let range_10s_shifted = range(10, &shifted); + let views = [ + sketch(SketchAlgorithm::Kll, &range_10s), + sketch(SketchAlgorithm::DDSketch, &range_10s), + sketch(SketchAlgorithm::Kll, &range_60s), + sketch(SketchAlgorithm::Kll, &range_10s_shifted), + sketch(SketchAlgorithm::Kll, &scan), + ]; + let mut nodes = vec![ + (scan, 0), + (range_10s, 1), + (views[0].clone(), 2), + (views[1].clone(), 3), + (range_60s, 4), + (views[2].clone(), 5), + (shifted, 6), + (range_10s_shifted, 7), + (views[3].clone(), 8), + (views[4].clone(), 9), + ]; + let at_ingestion = |node: usize, producers: &[&[usize]], view: bool| PhysicalCandidate { + timing: ExecutionTiming::IngestionTime, + materialization: if view { + Materialization::MatAtIngestion(Medium::Memory) + } else { + Materialization::NotMaterialized + }, + ..candidate(node, producers, false) + }; + let mut candidates = vec![ + at_ingestion(0, &[], false), + at_ingestion(1, &[&[0]], false), + at_ingestion(2, &[&[1]], true), + at_ingestion(3, &[&[1]], true), + at_ingestion(4, &[&[0]], false), + at_ingestion(5, &[&[4]], true), + at_ingestion(6, &[&[0]], false), + at_ingestion(7, &[&[6]], false), + at_ingestion(8, &[&[7]], true), + at_ingestion(9, &[&[0]], true), + ]; + let mut roots = Vec::new(); + for (index, view) in [2, 3, 5, 8, 9].into_iter().enumerate() { + let group = 10 + index as u32; + nodes.push((reader_of(&nodes[view].0), group)); + candidates.push(candidate(10 + index, &[&[view]], true)); + roots.push(root(group)); + } + catalog(nodes, candidates, roots) +} + +// One `I_r` per refresh window: the two views over `TimeRange(10 s)` share +// one; the 60 s view and the shifted 10 s view (window (10 s, 5 s), through +// a `NotMaterialized` shift and range) have their own; the unbounded view +// gets an `Alone` one. Each `I_r` holds the scan and the stateless nodes +// below its views, and each view's context reads across into its `I_r`. +#[test] +fn execution_contexts_have_one_ingestion_pipeline_per_refresh_window() { + let physical = ingestion_views_of_three_windows_and_an_unbounded_one(); + let links = CandidateLinks::new(&physical); + let alive = vec![true; physical.candidates.len()]; + let windows = refresh_windows(&physical, &links); + let window = |width_ms, phase_ms| RefreshWindow { width_ms, phase_ms }; + assert_eq!( + windows, + HashMap::from([ + (id(2), Some(window(10_000, 0))), + (id(3), Some(window(10_000, 0))), + (id(5), Some(window(60_000, 0))), + (id(8), Some(window(10_000, 5_000))), + (id(9), None), + ]) + ); + let contexts = execution_contexts( + &physical, + &links.kind, + &producer_candidates_left(&links, &alive), + &output_candidates(&physical, &alive), + &windows, + |_| true, + ); + let ingestion: Vec<(ContextKind, Vec)> = contexts.contexts[5..9] + .iter() + .map(|context| (context.kind, context.options.clone())) + .collect(); + let pipeline = |width_ms, phase_ms| { + ContextKind::Ingestion(IngestionPipeline::Window(window(width_ms, phase_ms))) + }; + assert_eq!( + ingestion, + vec![ + (pipeline(10_000, 0), vec![id(0), id(1)]), + (pipeline(10_000, 5_000), vec![id(0), id(6), id(7)]), + (pipeline(60_000, 0), vec![id(0), id(4)]), + ( + ContextKind::Ingestion(IngestionPipeline::Alone(id(9))), + vec![id(0)] + ), + ] + ); + assert_eq!( + contexts.ingestion_context_of_shareable, + BTreeMap::from([(id(2), 5), (id(3), 5), (id(5), 7), (id(8), 6), (id(9), 8)]) + ); + let shared = contexts.context_of_shareable[&id(8)]; + assert_eq!(contexts.contexts[shared].options, vec![id(7)]); +} + +// Every producer candidate of every input reads one view window, the +// assumption behind computing a refresh window from first producers. +#[test] +fn producers_of_an_input_read_one_view_window() { + let physical = ingestion_views_of_three_windows_and_an_unbounded_one(); + assert!(producer_window_disagreements_of_all(&physical).is_empty()); +} diff --git a/crates/plan-selection/src/milp/cost_terms.rs b/crates/plan-selection/src/milp/cost_terms.rs index dbc2483ab..6f5c3afc5 100644 --- a/crates/plan-selection/src/milp/cost_terms.rs +++ b/crates/plan-selection/src/milp/cost_terms.rs @@ -24,7 +24,7 @@ //! union is costed by `asap-cost` itself. //! //! **Executions per second** of a run: `rate(q)` in query context `q`; 1 in -//! `I` and for an ingestion-time shareable candidate; for a query-time +//! each `I_r` and for an ingestion-time shareable candidate; for a query-time //! shareable candidate `m` and the runs in its context, `Σ rate · z[m, G, //! ℓ]` over its schedule groups and their levels, `z ≥ a[q, m]` for each //! reader at that level or faster (a group's rate is its fastest reader's). @@ -211,11 +211,12 @@ fn producer_gives( let Some(outputs) = outputs else { return Some(read.into()); }; - // The producer's run: computed here, its own run, or its run in `I`. + // The producer's run: computed here, its own run, or its run in an + // `I_r`. let producer_run = match milp.kind(producer) { _ if milp.computes(context, producer) => context, RunKind::Shareable => milp.context_of(producer)?, - RunKind::Ingestion => milp.ingestion_context(), + RunKind::Ingestion => milp.ingestion_context_read_by(context)?, RunKind::PerContext => return None, }; if milp.terms_in_progress.contains(&(producer_run, producer)) { @@ -848,7 +849,7 @@ fn context_rates( for context in 0..milp.inputs.contexts.len() { let rate = match milp.inputs.contexts[context].kind { ContextKind::Query(query) => ContextRate::Fixed(query_rate[query]), - ContextKind::Ingestion => ContextRate::Fixed(1.0), + ContextKind::Ingestion(_) => ContextRate::Fixed(1.0), ContextKind::Shared(m) => { let per_request = match milp.physical.candidates[m.0].materialization { Materialization::MatAtIngestion(_) | Materialization::NotMaterialized => { @@ -1097,7 +1098,7 @@ pub(crate) fn encode_objective(milp: &mut SelectionMilp<'_>) -> Result<(), Selec charge(&held.into(), per_byte_ms * rate); } } - ContextKind::Ingestion => {} + ContextKind::Ingestion(_) => {} } } if let Some((_, most_per_slot)) = context_execution.get_mut(&context) { diff --git a/crates/plan-selection/src/milp/decode.rs b/crates/plan-selection/src/milp/decode.rs index dc62b8587..5b8b53466 100644 --- a/crates/plan-selection/src/milp/decode.rs +++ b/crates/plan-selection/src/milp/decode.rs @@ -3,7 +3,7 @@ //! //! - A candidate computed in a context (`p[ctx, c] = 1`, a `NotMaterialized` //! query-time candidate in a query's or a shareable candidate's context, -//! or a `NotMaterialized` ingestion-time candidate in `I`) is one node per +//! or a `NotMaterialized` ingestion-time candidate in an `I_r`) is one node per //! context: selected in two contexts, it is two nodes; read by two //! readers in one context, it is one. //! - A selected shareable candidate (`x[m] = 1`) is one node, whoever reads @@ -11,7 +11,7 @@ //! - In each context, input `n` of a computed node (or of `m`'s own node in //! `m`'s context) reads the context's selected candidate among its //! producers: the node computed there, the shareable candidate's node, or -//! the candidate's node in `I`. +//! the candidate's node in `I_r(m)` (read in ingestion-time `m`'s context). //! - Output reference `r` is the selected output candidate in `r`'s query //! context. //! @@ -56,7 +56,6 @@ pub(crate) fn decode( inputs: &MilpInputs, chosen: &ChosenCandidates, ) -> Result { - let ingestion = physical.query_roots.len(); // The node a read of `producer` in `context` resolves to. let run_of = |context: usize, producer: PhysicalCandidateId| -> Option { if !chosen.chosen[context].contains(&producer) { @@ -71,9 +70,10 @@ pub(crate) fn decode( .selected .contains(&producer) .then_some(Run::Shared(producer)), - RunKind::Ingestion => chosen.chosen[ingestion] - .contains(&producer) - .then_some(Run::Computed(ingestion, producer)), + RunKind::Ingestion => inputs + .ingestion_context_read_by(context) + .filter(|&ingestion| chosen.chosen[ingestion].contains(&producer)) + .map(|ingestion| Run::Computed(ingestion, producer)), RunKind::PerContext => None, } }; diff --git a/crates/plan-selection/src/milp/lp_diagnosis.rs b/crates/plan-selection/src/milp/lp_diagnosis.rs index c946d61b4..d1418de1c 100644 --- a/crates/plan-selection/src/milp/lp_diagnosis.rs +++ b/crates/plan-selection/src/milp/lp_diagnosis.rs @@ -190,7 +190,12 @@ impl Diagnosis { for (context, choices) in milp.choice.iter().enumerate() { let place = match milp.inputs.contexts[context].kind { crate::groups::ContextKind::Query(query) => format!("q{query}"), - crate::groups::ContextKind::Ingestion => "I".to_string(), + crate::groups::ContextKind::Ingestion(pipeline) => match pipeline { + crate::groups::IngestionPipeline::Window(window) => { + format!("I[{}ms@{}]", window.width_ms, window.phase_ms) + } + crate::groups::IngestionPipeline::Alone(m) => format!("I[alone m{}]", m.0), + }, crate::groups::ContextKind::Shared(m) => format!("m{}", m.0), }; for (&c, variable) in choices { diff --git a/crates/plan-selection/src/milp/model.rs b/crates/plan-selection/src/milp/model.rs index ee987788f..c71952886 100644 --- a/crates/plan-selection/src/milp/model.rs +++ b/crates/plan-selection/src/milp/model.rs @@ -5,7 +5,7 @@ //! //! | Family | Meaning | //! |---|---| -//! | `p[ctx, c]` | in context `ctx`, candidate `c` gives its result slot (binary): computed there, or read across the boundary (a shareable candidate, or a candidate of `I`) | +//! | `p[ctx, c]` | in context `ctx`, candidate `c` gives its result slot (binary): computed there, or read across the boundary (a shareable candidate, or a candidate of an ingestion pipeline `I_r`) | //! | `x[m]` | shareable candidate `m` is selected (binary) | //! | `a[q, m]` | query `q` depends on shareable `m` (continuous, exact at integral binaries) | //! | `y[q, m, c]` | `AND(a[q, m], p[m, c])`, for reach from above and accuracy (continuous) | @@ -22,9 +22,10 @@ //! 2. One candidate per result slot per context: `Σ_{c ∈ slot} p[ctx, c] ≤ 1`. //! 3. Validity: `Σ_{c' ∈ compat(c, n)} p[ctx, c'] ≥ p[ctx, c]` for each `c` //! computed in `ctx`; `≥ x[m]` for `m`'s own inputs in its context; -//! `p[ctx, m] ≤ x[m]`, `p[ctx, c] ≤ p[I, c]` for reads across a -//! boundary. -//! 4. Used only if read, per context. +//! `p[ctx, m] ≤ x[m]`, `p[m, c] ≤ p[I_r(m), c]` for reads across a +//! boundary (`r(m)` the refresh window of ingestion-time `m`). +//! 4. Used only if read, per context; a candidate of `I_r` is also read +//! by `p[m, c]` for each `m` with `r(m) = r`. //! 5. Sharing: `x[m] ≤ Σ_ctx p[ctx, m]`. //! 6. Reach: `a[q, m] ≥ p[q, m]`, `a[q, c] ≥ a[q, m] + p[m, c] − 1`; from //! above, where a reader constraint counts queries. @@ -86,7 +87,7 @@ pub(crate) struct SelectionMilpOptions { } /// Which part of the model a variable or row belongs to, for the size -/// report: the query contexts, `I`, the shareable candidates' contexts +/// report: the query contexts, the `I_r`, the shareable candidates' contexts /// (with their `x`, reach and schedule terms), or the rest. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] pub(crate) enum Scope { @@ -334,9 +335,10 @@ impl<'a> SelectionMilp<'a> { self.physical.query_roots.len() } - /// The index of `I` among the contexts. - pub(crate) fn ingestion_context(&self) -> usize { - self.query_count() + /// The context where `context` reads a candidate of an `I_r` + /// ([`MilpInputs::ingestion_context_read_by`]). + pub(crate) fn ingestion_context_read_by(&self, context: usize) -> Option { + self.inputs.ingestion_context_read_by(context) } /// The context of shareable candidate `m`. @@ -401,7 +403,7 @@ impl<'a> SelectionMilp<'a> { pub(crate) fn scope_of(&self, context: usize) -> Scope { match self.inputs.contexts[context].kind { ContextKind::Query(_) => Scope::Queries, - ContextKind::Ingestion => Scope::Ingestion, + ContextKind::Ingestion(_) => Scope::Ingestion, ContextKind::Shared(_) => Scope::SharedContexts, } } @@ -585,7 +587,7 @@ fn choice_variables(milp: &mut SelectionMilp<'_>) { } } } - ContextKind::Ingestion => {} + ContextKind::Ingestion(_) => {} } for &reader in &options { if !data.computes(inputs.kind[reader.0]) { @@ -635,7 +637,15 @@ fn choice_variables(milp: &mut SelectionMilp<'_>) { fn structural_rows(milp: &mut SelectionMilp<'_>) { let inputs = milp.inputs; let output_refs = output_references(&milp.physical.query_roots); - let ingestion = milp.ingestion_context(); + // Per `I_r`, the contexts of the ingestion-time shareable candidates of + // refresh window `r`. + let mut readers_of_ingestion: BTreeMap> = BTreeMap::new(); + for (m, &at) in &inputs.ingestion_context_of_shareable { + readers_of_ingestion + .entry(at) + .or_default() + .push(inputs.context_of_shareable[m]); + } // 1. Coverage. milp.builder.scope = Scope::Queries; for (reference, (query, _)) in output_refs.iter().enumerate() { @@ -687,7 +697,9 @@ fn structural_rows(milp: &mut SelectionMilp<'_>) { .row("3 validity (boundary)", constraint!(variable <= selected)); } RunKind::Ingestion => { - let source = milp.choice[ingestion].get(&option).copied(); + let source = milp + .ingestion_context_read_by(context) + .and_then(|ingestion| milp.choice[ingestion].get(&option).copied()); match source { Some(source) => milp .builder @@ -763,13 +775,11 @@ fn structural_rows(milp: &mut SelectionMilp<'_>) { } } } - if data.kind == ContextKind::Ingestion { - for other in 0..inputs.contexts.len() { - if other != context { - if let Some(&read) = milp.choice[other].get(&option) { - reads += read; - } - } + // A candidate of `I_r` is also read by the contexts of the + // ingestion-time shareable candidates of refresh window `r`. + for &other in readers_of_ingestion.get(&context).into_iter().flatten() { + if let Some(&read) = milp.choice[other].get(&option) { + reads += read; } } milp.builder @@ -848,40 +858,50 @@ fn acyclic_rows(milp: &mut SelectionMilp<'_>) { } /// Order ranks across contexts: on the graph whose nodes are the shareable -/// candidates' own runs and the runs of `I`, with an edge from a run to -/// each shareable candidate or candidate of `I` its context may read (for -/// a run of `I`, its producers in `I`). Rows 3–5 alone admit a cycle of -/// reads across contexts that justifies itself with no output above it -/// (two shareable candidates of one memo group, each listed among the -/// other's producers); only edges inside a cyclic component get a row. +/// candidates' own runs and the runs of each `I_r`, with an edge from a run +/// to each shareable candidate or candidate of an `I_r` its context may +/// read (for a run of `I_r`, its producers there). Rows 3–5 alone admit a +/// cycle of reads across contexts that justifies itself with no output +/// above it (two shareable candidates of one memo group, each listed among +/// the other's producers); only edges inside a cyclic component get a row. fn acyclic_context_rows(milp: &mut SelectionMilp<'_>) { let inputs = milp.inputs; - let ingestion = milp.ingestion_context(); // Nodes: (context of the run, candidate). let mut nodes: Vec<(usize, PhysicalCandidateId)> = Vec::new(); for (&m, &context) in &inputs.context_of_shareable { nodes.push((context, m)); } - for &c in milp.choice[ingestion].keys() { - if milp.computes(ingestion, c) { - nodes.push((ingestion, c)); + for (context, data) in inputs.contexts.iter().enumerate() { + if let ContextKind::Ingestion(_) = data.kind { + for &c in milp.choice[context].keys() { + if milp.computes(context, c) { + nodes.push((context, c)); + } + } } } - let index: HashMap = nodes - .iter() - .enumerate() - .map(|(i, &(_, c))| (c, i)) - .collect(); + let index: HashMap<(usize, PhysicalCandidateId), usize> = + nodes.iter().enumerate().map(|(i, &run)| (run, i)).collect(); + // The node a read of `option` in `context` resolves to. + let node_read = |context: usize, option: PhysicalCandidateId| -> Option { + let run_context = match inputs.kind[option.0] { + RunKind::Shareable => milp.context_of(option)?, + RunKind::Ingestion => milp.ingestion_context_read_by(context)?, + RunKind::PerContext => return None, + }; + index.get(&(run_context, option)).copied() + }; // Edges with their activity: `(from, to, indicators that must all be 1)`. let mut edges: Vec<(usize, usize, Vec)> = Vec::new(); for (from, &(context, candidate)) in nodes.iter().enumerate() { - if context == ingestion { - let reader = milp.choice[ingestion][&candidate]; + if let ContextKind::Ingestion(_) = inputs.contexts[context].kind { + let reader = milp.choice[context][&candidate]; for producers in &inputs.producer_candidates_per_input[candidate.0] { - for producer in producers { - if let (Some(&to), Some(&read)) = - (index.get(producer), milp.choice[ingestion].get(producer)) - { + for &producer in producers { + if let (Some(to), Some(&read)) = ( + node_read(context, producer), + milp.choice[context].get(&producer), + ) { if to != from { edges.push((from, to, vec![reader, read])); } @@ -890,10 +910,8 @@ fn acyclic_context_rows(milp: &mut SelectionMilp<'_>) { } } else { for (&option, &read) in &milp.choice[context] { - if let Some(&to) = index.get(&option) { - if inputs.kind[option.0] != RunKind::PerContext { - edges.push((from, to, vec![read])); - } + if let Some(to) = node_read(context, option) { + edges.push((from, to, vec![read])); } } } @@ -1204,7 +1222,7 @@ fn bucket_queries(query_of_reference: &[usize], query_outputs: &[Vec]) -> /// Row 9: reader constraints, on every node of the constrained candidate. /// A node in a query's context is reached by that query alone; a node in /// shareable `m`'s context, and `m` itself, by the queries that depend on -/// `m`. Reads into `I` and per-output-reference reach inside one query +/// `m`. Reads into an `I_r` and per-output-reference reach inside one query /// are not modeled: the constraint is then applied to every reference of /// the query (conservative, counted as inexact). fn reader_constraint_rows(milp: &mut SelectionMilp<'_>) { @@ -1261,9 +1279,9 @@ fn reader_constraint_rows(milp: &mut SelectionMilp<'_>) { let count = match inputs.contexts[context].kind { ContextKind::Query(_) => Expression::from(1.0), ContextKind::Shared(m) => reach_count(milp, &queries, m).0, - ContextKind::Ingestion => { + ContextKind::Ingestion(_) => { milp.note_inexact( - "minimum readers of a candidate of I (forbidden)", + "minimum readers of a candidate of an I_r (forbidden)", ); Expression::from(0.0) } @@ -1322,9 +1340,9 @@ fn reader_constraint_rows(milp: &mut SelectionMilp<'_>) { constraint!(count <= 1.0 + slack * (1.0 - used)), ); } - ContextKind::Ingestion => { + ContextKind::Ingestion(_) => { milp.note_inexact( - "highest shared materialization in I (forbidden)", + "highest shared materialization in an I_r (forbidden)", ); milp.builder .row("9 reader constraints", constraint!(used <= 0.0)); @@ -1409,8 +1427,8 @@ fn forbid_reach( .row("9 reader constraints", constraint!(reach + used <= 1.0)); } } - ContextKind::Ingestion => { - milp.note_inexact("a reader constraint on a candidate of I (forbidden)"); + ContextKind::Ingestion(_) => { + milp.note_inexact("a reader constraint on a candidate of an I_r (forbidden)"); milp.builder .row("9 reader constraints", constraint!(used <= 0.0)); } @@ -1608,8 +1626,8 @@ fn accuracy_rows(milp: &mut SelectionMilp<'_>) { } None => None, }, - ContextKind::Ingestion => { - milp.note_inexact("an approximate candidate of I (forbidden)"); + ContextKind::Ingestion(_) => { + milp.note_inexact("an approximate candidate of an I_r (forbidden)"); let p = milp.choice[context][&candidate]; milp.builder.row("8 accuracy", constraint!(p <= 0.0)); None @@ -1700,7 +1718,7 @@ pub(crate) struct IndependentPart { /// The independent parts of `milp`, in order of their first query (the /// part without a query last). Queries fall into one part when they may -/// share a shareable candidate, a run of `I`, a source's raw retention or +/// share a shareable candidate, a run of an `I_r`, a source's raw retention or /// anything else a row joins. pub(crate) fn independent_parts(milp: &SelectionMilp<'_>) -> Vec { let column_of: HashMap = milp diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs index ba87120eb..e11489fcc 100644 --- a/crates/plan-selection/src/milp/model/tests.rs +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -1,8 +1,8 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; use super::*; -use crate::groups::{prepare_milp_inputs, AccuracyWeight}; +use crate::groups::{prepare_milp_inputs, AccuracyWeight, IngestionPipeline}; use crate::milp::decode::{decode, ChosenCandidates}; use asap_cost::{node_map, plan_cost, PlanCost, ResourceUnitCost}; use asap_types::ir::properties::ExecutionTiming; @@ -567,7 +567,7 @@ fn a_shared_results_not_materialized_input_runs_once() { } // One ingestion scan read by sketches of two configurations (two quantile -// targets) runs once in `I` and is costed once. +// targets) runs once in its `I_r` and is costed once. #[test] fn one_ingestion_scan_shared_by_two_sketch_configurations_is_charged_once() { let fixture = through_stages_with( @@ -616,6 +616,177 @@ fn one_ingestion_scan_shared_by_two_sketch_configurations_is_charged_once() { assert_eq!(ingestion_scans.len(), 1, "{:?}", checked.dag); } +/// Solves `queries` (ingesting) with two ingestion-time sketch states +/// selected, every query-time scan forbidden and every ingestion-time state +/// whose refresh window is not one of `widths_ms` (phase 0) forbidden; +/// returns the checked solve (objective = `plan_cost`) and its +/// ingestion-time scan runs, each with the refresh windows of the states +/// reading it. +fn ingestion_scans_under_refresh_windows( + queries: &[Query<'_>], + widths_ms: &[u64], +) -> (Checked, Vec>) { + use asap_types::ir::materialized_view::RefreshWindow; + let fixture = through_stages_with(queries, true, true); + let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { + let states = candidates_where(milp, |candidate, _| { + candidate.timing == ExecutionTiming::IngestionTime + && candidate.materialization != Materialization::NotMaterialized + }); + let window_of = |state: PhysicalCandidateId| { + milp.inputs + .ingestion_context_of_shareable + .get(&state) + .map(|&context| milp.inputs.contexts[context].kind) + }; + let (allowed, other): (Vec<_>, Vec<_>) = states.iter().partition(|&&state| { + widths_ms.iter().any(|&width_ms| { + window_of(state) + == Some(ContextKind::Ingestion(IngestionPipeline::Window( + RefreshWindow { + width_ms, + phase_ms: 0, + }, + ))) + }) + }); + forbid(milp, &other); + let sketches = candidates_where(milp, |_, node| { + matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) + }); + let sum: Expression = allowed + .iter() + .filter(|state| sketches.contains(state)) + .filter_map(|state| milp.selected.get(state).copied()) + .map(Expression::from) + .sum(); + milp.builder.row("test", constraint!(sum >= 2.0)); + let scans = candidates_where(milp, |candidate, node| { + candidate.timing == ExecutionTiming::QueryTime && node.operator.kind_name() == "Scan" + }); + forbid(milp, &scans); + }) + .unwrap(); + // Per ingestion-time scan node of the decoded selection: the pane widths + // of the chains above it. + let dag = &checked.dag; + let nodes = dag.candidate_of_node.len(); + let mut readers: Vec> = vec![Vec::new(); nodes]; + for edge in &dag.edges { + readers[edge.producer.0].push(edge.consumer.0); + } + let width_above = |scan: usize| { + let mut widths = BTreeSet::new(); + let mut stack = vec![scan]; + let mut seen = BTreeSet::new(); + while let Some(node) = stack.pop() { + if !seen.insert(node) { + continue; + } + let candidate = dag.candidate_of_node[node]; + match fixture.physical.physical_node(candidate).asap() { + Some(asap_types::ir::ASAPOp::PaneChain { pane_width_ms, .. }) => { + widths.insert(*pane_width_ms); + } + _ => stack.extend(readers[node].iter().copied()), + } + } + widths + }; + let scans = (0..nodes) + .filter(|&node| { + let candidate = dag.candidate_of_node[node]; + fixture.physical.candidates[candidate.0].timing == ExecutionTiming::IngestionTime + && fixture + .physical + .physical_node(candidate) + .operator + .kind_name() + == "Scan" + }) + .map(width_above) + .collect(); + (checked, scans) +} + +// Two ingestion-time pane chains of one refresh window (10 s panes) over +// one source run in one ingestion pipeline: the scan below them runs and +// is charged once, and the objective equals `plan_cost`. +#[test] +fn one_ingestion_scan_under_chains_of_one_refresh_window_is_charged_once() { + let (_, scans) = ingestion_scans_under_refresh_windows( + &[ + approximate("quantile_over_time(0.5, m[5m])", 60_000, 0.01), + approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.05), + ], + &[10_000], + ); + assert_eq!(scans, vec![BTreeSet::from([10_000])]); +} + +// Chains of 10 s and 60 s panes over one source run in two ingestion +// pipelines (#1151): the scan runs and is charged once in each, and the +// objective equals `plan_cost`, which charges it once per refresh window. +#[test] +fn ingestion_scans_under_chains_of_two_refresh_windows_are_charged_twice() { + let (_, scans) = ingestion_scans_under_refresh_windows( + &[ + approximate("quantile_over_time(0.5, m[10s])", 10_000, 0.01), + approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.05), + ], + &[10_000, 60_000], + ); + let mut scans = scans; + scans.sort(); + assert_eq!( + scans, + vec![BTreeSet::from([10_000]), BTreeSet::from([60_000])] + ); +} + +// Every producer candidate of every input reads one view window on real +// Stage 2 output (ingesting, and not), so a refresh window computed from +// first producers is the realized plan's. +#[test] +fn producers_of_an_input_read_one_view_window_through_stages() { + let fixtures = [ + through_stages_with( + &[ + approximate("quantile_over_time(0.5, m[10s])", 10_000, 0.01), + approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.05), + ], + true, + true, + ), + through_stages_with( + &[ + query("sum by (job) (rate(m[1m]))", 30_000), + query("sum(increase(m[5m] offset 1m))", 60_000), + approximate( + "quantile_over_time(0.5, m[5m]) / quantile_over_time(0.9, m[5m])", + 60_000, + 0.05, + ), + ], + true, + true, + ), + through_stages( + &[ + query("avg_over_time(m[5m])", 60_000), + query("max_over_time(m[1m])", 60_000), + ], + true, + ), + ]; + for fixture in &fixtures { + assert_eq!( + crate::groups::producer_window_disagreements_of_all(&fixture.physical), + Vec::new() + ); + } +} + // A stateless subtree read by two state nodes of one query evaluation // (`avg` reads a sum and a count accumulator over one `q50/q50 + q90` // subtree) is computed once: no candidate runs twice in the plan. @@ -977,7 +1148,7 @@ fn a_widened_scan_reading_its_own_memo_group_plans_exactly() { } } -// #1095 §5 (3a): queries over two metrics share no candidate, run of `I` or +// #1095 §5 (3a): queries over two metrics share no candidate, run of an `I_r` or // source, so the model splits into one part per metric; solving the parts // apart gives the plan and cost of one solve of the whole model. #[test] diff --git a/crates/plan-selection/src/milp/warm_start.rs b/crates/plan-selection/src/milp/warm_start.rs index beb5e59e3..9f520a3b2 100644 --- a/crates/plan-selection/src/milp/warm_start.rs +++ b/crates/plan-selection/src/milp/warm_start.rs @@ -10,7 +10,8 @@ //! candidate of that slot, which every reader must then accept (one //! candidate per slot per context); a dead end backtracks to the next //! candidate. A shareable candidate's context is derived once, when it is -//! first selected; a candidate of `I` is chosen in `I`. +//! first selected; a candidate of an `I_r` is chosen in the `I_r` of the +//! reading context's refresh window. //! //! **Repairs per query.** If the query's plan depends on more approximate //! values than one of them is sized for, on a pair excluded together, or @@ -19,7 +20,7 @@ //! planned again by latency alone. //! //! **Greedy sharing.** A second plan prices what the queries planned -//! before already selected (a shareable candidate, a candidate of `I`) at +//! before already selected (a shareable candidate, a candidate of an `I_r`) at //! zero, so later queries read it. Both plans, in workload order and //! reversed, are costed exactly by the MILP's own rows and objective //! ([`Completion`]); the cheapest feasible one is the start. @@ -368,7 +369,7 @@ impl Estimates { .iter() .map(|context| match context.kind { ContextKind::Query(query) => query_rate[query], - ContextKind::Ingestion => 1.0, + ContextKind::Ingestion(_) => 1.0, ContextKind::Shared(m) if context.at_query_time => { let readers = milp.queries_reaching(m); readers @@ -501,10 +502,12 @@ impl Derivation<'_, '_> { None => return f64::INFINITY, }, RunKind::Ingestion => { - let ingestion = milp.ingestion_context(); - if !milp.choice[ingestion].contains_key(&candidate) { + let Some(ingestion) = milp + .ingestion_context_read_by(context) + .filter(|&at| milp.choice[at].contains_key(&candidate)) + else { return f64::INFINITY; - } + }; let chosen = plan.chosen[ingestion].get(&inputs.slot[candidate.0]); (ingestion, chosen == Some(&candidate)) } @@ -651,7 +654,7 @@ impl Derivation<'_, '_> { Ok(()) } RunKind::Ingestion => { - let ingestion = milp.ingestion_context(); + let ingestion = milp.ingestion_context_read_by(context).ok_or(())?; if context == ingestion { return Err(()); } @@ -696,7 +699,10 @@ fn runs_reached( Some(own) => (own, true), None => continue, }, - RunKind::Ingestion => (milp.ingestion_context(), false), + RunKind::Ingestion => match milp.ingestion_context_read_by(context) { + Some(ingestion) => (ingestion, false), + None => continue, + }, RunKind::PerContext => continue, } }; diff --git a/crates/plan-selection/src/search/costing/direct_profile.rs b/crates/plan-selection/src/search/costing/direct_profile.rs index 68c6a72f1..292c88084 100644 --- a/crates/plan-selection/src/search/costing/direct_profile.rs +++ b/crates/plan-selection/src/search/costing/direct_profile.rs @@ -491,10 +491,9 @@ fn profile_for_priced( // profile admits no `TimeShift` at ingestion, so that tumbling window ends at // the evaluation time: phase 0. refresh_window: match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - .. - }) if node.materialization == Materialization::MatAtIngestion(Medium::Memory) => { + asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { tumbling_window_width_ms, .. }) + if node.materialization == Materialization::MatAtIngestion(Medium::Memory) => + { RefreshWindow::of_window(0, i64::try_from(*tumbling_window_width_ms).unwrap_or(0)) } _ => None, diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index ec5dcc6dd..da476885d 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -503,15 +503,13 @@ impl PhysicalASAPDAG { /// `Self::view_window` of each of `nodes`, by id, with the DAG indexed /// once for all of them: O(nodes + edges + the nodes walked below each). + /// It walks each operator's inputs (a valid DAG's edges), so it also + /// answers for a DAG of nodes alone. pub fn view_windows( &self, nodes: impl IntoIterator, ) -> HashMap { let payloads = self.payloads_by_id(); - let mut inputs: HashMap> = HashMap::new(); - for edge in &self.edges { - inputs.entry(edge.consumer).or_default().push(edge.producer); - } let window_of = |node: PhysicalASAPNodeId| { let mut seen = HashSet::new(); let mut pending = vec![node]; @@ -530,8 +528,7 @@ impl PhysicalASAPDAG { tumbling_window_count, .. })) => { - let width_ms = - tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count)); + let width_ms = tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count)); match ( offset_below(*child, |id| payloads.get(&id).copied()), width_ms, @@ -543,10 +540,11 @@ impl PhysicalASAPDAG { _ => ScanWindow::Unbounded, } } - _ => { - pending.extend(inputs.get(&id).into_iter().flatten()); + Some(payload) => { + pending.extend(payload.children().into_iter().copied()); continue; } + None => continue, }; window = Some(window.map_or(read, |window| window.union(read))); } diff --git a/crates/types/tests/materialized_view.rs b/crates/types/tests/materialized_view.rs index 2e51ec49f..7725e5e22 100644 --- a/crates/types/tests/materialized_view.rs +++ b/crates/types/tests/materialized_view.rs @@ -306,12 +306,7 @@ fn a_chain_and_each_of_its_tumbling_windows_have_one_refresh_window() { let chains: Vec = unexpanded .nodes .iter() - .filter(|node| { - matches!( - node.payload, - Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) - ) - }) + .filter(|node| matches!(node.payload, Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }))) .map(|node| node.id) .collect(); assert_eq!(chains.len(), 3); @@ -507,3 +502,25 @@ fn a_node_above_an_unexpanded_chain_has_the_window_of_its_tumbling_windows() { }) ); } + +/// A non-chain view (a KLL over `TimeRange(1 min)` shifted 15 s) has the +/// same refresh window, (60 s, 15 s), on a DAG of its nodes alone (no +/// edges, what Stage 3 builds from candidates): `view_windows` walks each +/// operator's inputs. +#[test] +fn a_view_has_its_refresh_window_on_a_dag_without_edges() { + let view = pane(&scan_m(), 15_000); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&view, Materialization::MatAtIngestion(Medium::Memory)); + let timed = apply_materialization_timings(&view, &assignment, &mut NodeToTimedNodeCache::new()) + .unwrap(); + let mut dag = compile_physical_asap_dag(&timed).unwrap(); + let view_id = dag.roots[0]; + let window = Some(RefreshWindow { + width_ms: 60_000, + phase_ms: 15_000, + }); + assert_eq!(dag.refresh_window(view_id), window); + dag.edges.clear(); + assert_eq!(dag.refresh_window(view_id), window); +} From 2766e428df2fbde0d4d73b3703ea7d1fad80c6c4 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:58:04 +0000 Subject: [PATCH 7/9] fix(plan-selection): use the tumbling-window names (#1163) in the group MILP after rebasing onto main The group MILP commits were written before #1163 renamed PaneChain to SummaryTumblingWindowChain and pane_width_ms / pane_count / newest_panes / pane_retention_ms to their tumbling-window names; rename their uses, and rustfmt the lines the rebase resolved. --- crates/cost/src/lib.rs | 6 +-- crates/plan-selection/src/groups.rs | 53 ++++++++++--------- crates/plan-selection/src/groups/tests.rs | 12 ++--- crates/plan-selection/src/milp/model/tests.rs | 31 +++++++---- .../src/search/costing/direct_profile.rs | 7 +-- crates/plan-selection/src/select.rs | 8 +-- crates/types/src/ir/physical_export.rs | 3 +- crates/types/tests/materialized_view.rs | 7 ++- 8 files changed, 75 insertions(+), 52 deletions(-) diff --git a/crates/cost/src/lib.rs b/crates/cost/src/lib.rs index e2570f9f3..1f6536faf 100644 --- a/crates/cost/src/lib.rs +++ b/crates/cost/src/lib.rs @@ -41,9 +41,9 @@ pub use measured::{ pub use operator_statistics::EdgeStatistics; pub use ranking::{cheaper, worst_latency_ms, PlanRanking}; pub use usage::{ - family_name, kept_tumbling_window_chains, maintained_window_ms, node_map, node_usage, tumbling_window_retention_ms, - tumbling_window_retention_ms_of_plan, tumbling_windows_of, plan_cost, query_reach, retained_tumbling_windows, trailing_window, - NodeMap, NodeUsage, ProducerOutput, + family_name, kept_tumbling_window_chains, maintained_window_ms, node_map, node_usage, tumbling_windows_of, plan_cost, + query_reach, retained_tumbling_windows, trailing_window, tumbling_window_retention_ms, + tumbling_window_retention_ms_of_plan, NodeMap, NodeUsage, ProducerOutput, }; /// Cost per second of wall time; one unit is one CPU-millisecond under diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs index 7c6bcd4c6..375b6d9ed 100644 --- a/crates/plan-selection/src/groups.rs +++ b/crates/plan-selection/src/groups.rs @@ -31,7 +31,7 @@ //! [`PhysicalASAPDAG::scan_window`] for a plan with that one reader); //! - for a pane chain kept beyond one evaluation, the panes it keeps, which //! grow with the range-evaluation span of the queries reaching it -//! ([`asap_cost::pane_retention_ms`], [`asap_cost::retained_panes`]). +//! ([`asap_cost::tumbling_window_retention_ms`], [`asap_cost::retained_panes`]). //! //! Every combination of these options is costed (at most //! [`MAX_CONTEXTS`]); a combination `asap-cost` cannot cost is skipped @@ -75,7 +75,7 @@ //! ## Accuracy weights //! //! `w(q, k)` says whether query `q` may read approximate candidate `k` (a -//! sketch [`ASAPOp::SummaryAgg`] or [`ASAPOp::PaneChain`], what +//! sketch [`ASAPOp::SummaryAgg`] or [`ASAPOp::SummaryTumblingWindowChain`], what //! [`asap_accuracy::propagation::approximate_inputs`] counts), and pairs of //! approximate candidates `q` may not read together. Both come from the //! composition itself, never from an operator table @@ -169,7 +169,7 @@ pub(crate) struct MilpInputs { #[derive(Debug, Clone)] pub(crate) struct CandidateData { /// Whether it outputs approximate values: a sketch `SummaryAgg` or - /// `PaneChain`, each an approximate input of the queries reading it. + /// `SummaryTumblingWindowChain`, each an approximate input of the queries reading it. pub(crate) approximate: bool, /// `parts(k)`: its operator node's `accuracy_budget_parts` (Stage 2's /// `OperatorNodeOfCandidateForMemoGroup`: for a factored node, the @@ -1030,7 +1030,7 @@ fn outputs_approximate_values(node: &OperatorNode) -> bool { ASAPOp::SummaryAgg { family: FieldDataType::Sketch(..), .. - } | ASAPOp::PaneChain { + } | ASAPOp::SummaryTumblingWindowChain { family: FieldDataType::Sketch(..), .. } @@ -1076,7 +1076,7 @@ fn catalog_node( } /// A DAG of `nodes` alone, without edges: what -/// [`asap_cost::pane_retention_ms`] reads (payload children and nodes). +/// [`asap_cost::tumbling_window_retention_ms`] reads (payload children and nodes). fn nodes_dag(nodes: &[&PhysicalASAPDAGNode]) -> PhysicalASAPDAG { PhysicalASAPDAG { nodes: nodes.iter().map(|&node| node.clone()).collect(), @@ -1100,19 +1100,21 @@ fn window_read_through(path: &[&PhysicalASAPDAGNode]) -> ScanWindow { continue; } Operator::NonASAP(NonASAPOp::TimeRange { range, .. }) => range.as_millis(), - Operator::ASAP(ASAPOp::PaneChain { pane_width_ms, .. }) - if matches!( - node.materialization, - Materialization::MatAtQueryKeptAcrossEvaluations(_) - ) => + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + .. + }) if matches!( + node.materialization, + Materialization::MatAtQueryKeptAcrossEvaluations(_) + ) => { - u128::from(*pane_width_ms) + u128::from(*tumbling_window_width_ms) } - Operator::ASAP(ASAPOp::PaneChain { - pane_width_ms, - pane_count, + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + tumbling_window_count, .. - }) => u128::from(*pane_width_ms) * u128::from(*pane_count), + }) => u128::from(*tumbling_window_width_ms) * u128::from(*tumbling_window_count), _ => return ScanWindow::Unbounded, }; return ScanWindow::Within { @@ -1323,7 +1325,7 @@ fn retained_pane_options( let kept = matches!( (&node.payload, node.materialization), ( - Operator::ASAP(ASAPOp::PaneChain { .. }), + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }), Materialization::MatAtIngestion(_) | Materialization::MatAtQueryKeptAcrossEvaluations(_) ) @@ -1359,7 +1361,7 @@ fn retained_pane_options( .collect(), }; for range in spans { - let retention = asap_cost::pane_retention_ms(&dag, &reach, &[range]); + let retention = asap_cost::tumbling_window_retention_ms(&dag, &reach, &[range]); let panes = asap_cost::retained_panes(node, &retention); options.push(panes); } @@ -1974,7 +1976,10 @@ pub(crate) struct DominanceKey<'a> { /// pane chain's panes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum OperatorKindForReaders { - PaneChain { pane_width_ms: u64, pane_count: u32 }, + SummaryTumblingWindowChain { + tumbling_window_width_ms: u64, + tumbling_window_count: u32, + }, Other(std::mem::Discriminant, &'static str), } @@ -2018,13 +2023,13 @@ fn dominance_profiles<'a>( .may_keep_across_evaluations_per_sole_reader_query, output_schema: &node.schema, operator_kind: match &node.operator { - Operator::ASAP(ASAPOp::PaneChain { - pane_width_ms, - pane_count, + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + tumbling_window_count, .. - }) => OperatorKindForReaders::PaneChain { - pane_width_ms: *pane_width_ms, - pane_count: *pane_count, + }) => OperatorKindForReaders::SummaryTumblingWindowChain { + tumbling_window_width_ms: *tumbling_window_width_ms, + tumbling_window_count: *tumbling_window_count, }, operator => OperatorKindForReaders::Other( std::mem::discriminant(operator), diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index be102c451..6a9f6ce36 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -273,9 +273,9 @@ fn dominance_needs_every_condition() { ( "operator kind", DominanceKey { - operator_kind: OperatorKindForReaders::PaneChain { - pane_width_ms: 60_000, - pane_count: 5, + operator_kind: OperatorKindForReaders::SummaryTumblingWindowChain { + tumbling_window_width_ms: 60_000, + tumbling_window_count: 5, }, ..key.clone() }, @@ -646,7 +646,7 @@ fn two_queries_share_a_pane_chain_through_the_real_pipeline() { .filter(|candidate| { matches!( physical.physical_node(*candidate).asap(), - Some(ASAPOp::PaneChain { .. }) + Some(ASAPOp::SummaryTumblingWindowChain { .. }) ) && links.is_shareable(*candidate) && inputs.contexts[0].options.contains(candidate) && inputs.contexts[1].options.contains(candidate) @@ -701,7 +701,7 @@ fn sketch_weights( family: FieldDataType::Sketch(kind, _), .. } - | ASAPOp::PaneChain { + | ASAPOp::SummaryTumblingWindowChain { family: FieldDataType::Sketch(kind, _), .. }, @@ -796,7 +796,7 @@ fn one_pane_chain_serves_a_ratio_and_its_operands() { .physical_node(PhysicalCandidateId(class)) .asap() { - Some(ASAPOp::PaneChain { + Some(ASAPOp::SummaryTumblingWindowChain { family: FieldDataType::Sketch(kind, _), .. }) => match kind.params() { diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs index e11489fcc..d7e668c45 100644 --- a/crates/plan-selection/src/milp/model/tests.rs +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -344,7 +344,10 @@ fn forbid(milp: &mut SelectionMilp<'_>, candidates: &[PhysicalCandidateId]) { fn pane_chains_and_ingestion(milp: &SelectionMilp<'_>) -> Vec { candidates_where(milp, |candidate, node| { candidate.timing == ExecutionTiming::IngestionTime - || matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) + || matches!( + node.asap(), + Some(asap_types::ir::ASAPOp::SummaryTumblingWindowChain { .. }) + ) }) } @@ -491,11 +494,13 @@ fn a_kept_pane_chain_keeps_the_longest_readers_span() { ); let checked = solve_and_check(&fixture, &cost_inputs(), |milp| { let chains = candidates_where(milp, |candidate, node| { - matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) - && matches!( - candidate.materialization, - Materialization::MatAtIngestion(_) - ) + matches!( + node.asap(), + Some(asap_types::ir::ASAPOp::SummaryTumblingWindowChain { .. }) + ) && matches!( + candidate.materialization, + Materialization::MatAtIngestion(_) + ) }); require_one_of(milp, &chains); }) @@ -585,7 +590,7 @@ fn one_ingestion_scan_shared_by_two_sketch_configurations_is_charged_once() { && matches!( node.asap(), Some(asap_types::ir::ASAPOp::SummaryAgg { .. }) - | Some(asap_types::ir::ASAPOp::PaneChain { .. }) + | Some(asap_types::ir::ASAPOp::SummaryTumblingWindowChain { .. }) ) }); let sum: Expression = sketches @@ -652,7 +657,10 @@ fn ingestion_scans_under_refresh_windows( }); forbid(milp, &other); let sketches = candidates_where(milp, |_, node| { - matches!(node.asap(), Some(asap_types::ir::ASAPOp::PaneChain { .. })) + matches!( + node.asap(), + Some(asap_types::ir::ASAPOp::SummaryTumblingWindowChain { .. }) + ) }); let sum: Expression = allowed .iter() @@ -685,8 +693,11 @@ fn ingestion_scans_under_refresh_windows( } let candidate = dag.candidate_of_node[node]; match fixture.physical.physical_node(candidate).asap() { - Some(asap_types::ir::ASAPOp::PaneChain { pane_width_ms, .. }) => { - widths.insert(*pane_width_ms); + Some(asap_types::ir::ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + .. + }) => { + widths.insert(*tumbling_window_width_ms); } _ => stack.extend(readers[node].iter().copied()), } diff --git a/crates/plan-selection/src/search/costing/direct_profile.rs b/crates/plan-selection/src/search/costing/direct_profile.rs index 292c88084..68c6a72f1 100644 --- a/crates/plan-selection/src/search/costing/direct_profile.rs +++ b/crates/plan-selection/src/search/costing/direct_profile.rs @@ -491,9 +491,10 @@ fn profile_for_priced( // profile admits no `TimeShift` at ingestion, so that tumbling window ends at // the evaluation time: phase 0. refresh_window: match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { tumbling_window_width_ms, .. }) - if node.materialization == Materialization::MatAtIngestion(Medium::Memory) => - { + asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { + tumbling_window_width_ms, + .. + }) if node.materialization == Materialization::MatAtIngestion(Medium::Memory) => { RefreshWindow::of_window(0, i64::try_from(*tumbling_window_width_ms).unwrap_or(0)) } _ => None, diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs index 877341c6b..1d7bba0dd 100644 --- a/crates/plan-selection/src/select.rs +++ b/crates/plan-selection/src/select.rs @@ -288,7 +288,7 @@ pub(crate) fn realize( }) } -/// The selected nodes whose summary (`SummaryAgg` or `PaneChain`) reads +/// The selected nodes whose summary (`SummaryAgg` or `SummaryTumblingWindowChain`) reads /// update weights `data` proves non-negative although the candidate leaves /// the requirement open. pub(crate) fn proven_non_negative_weights( @@ -300,9 +300,9 @@ pub(crate) fn proven_non_negative_weights( .iter() .enumerate() .filter(|(_, node)| match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { input, .. } | ASAPOp::PaneChain { input, .. }) => { - confirmed_weights(&input.weight_domain, data) != input.weight_domain - } + Operator::ASAP( + ASAPOp::SummaryAgg { input, .. } | ASAPOp::SummaryTumblingWindowChain { input, .. }, + ) => confirmed_weights(&input.weight_domain, data) != input.weight_domain, _ => false, }) .map(|(node, _)| SelectedNodeId(node)) diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index da476885d..b508d81ea 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -528,7 +528,8 @@ impl PhysicalASAPDAG { tumbling_window_count, .. })) => { - let width_ms = tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count)); + let width_ms = + tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count)); match ( offset_below(*child, |id| payloads.get(&id).copied()), width_ms, diff --git a/crates/types/tests/materialized_view.rs b/crates/types/tests/materialized_view.rs index 7725e5e22..3735e6008 100644 --- a/crates/types/tests/materialized_view.rs +++ b/crates/types/tests/materialized_view.rs @@ -306,7 +306,12 @@ fn a_chain_and_each_of_its_tumbling_windows_have_one_refresh_window() { let chains: Vec = unexpanded .nodes .iter() - .filter(|node| matches!(node.payload, Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }))) + .filter(|node| { + matches!( + node.payload, + Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) + ) + }) .map(|node| node.id) .collect(); assert_eq!(chains.len(), 3); From 079507f8341bbd5a0d521542f51459b76fe48349 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:23:27 +0000 Subject: [PATCH 8/9] feat(plan-selection): the group MILP is Stage 3's default search (#1095 C6) select_plan runs the group MILP unless ASAP_STAGE3_SEARCH=old. Inputs the MILP does not price yet (cost models 2 and 3, or a measured operation cost table without a frozen calibration factor) are an UnsupportedCostModel error; there is no fallback to the old engine. --- crates/devtools/src/bin/stage_pipeline.rs | 7 ++++--- crates/devtools/tests/stage_pipeline.rs | 4 ++-- crates/plan-selection/src/search/select.rs | 6 +++--- crates/plan-selection/src/select.rs | 19 ++++++++++++------- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index c59d023b1..feb8c553a 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -74,9 +74,10 @@ // `pruned_candidates` and `search_report` (`SearchReport`: `status`, // `cost_lower_bound_per_second`, MILP size and timings, and // `inexact_objective` when the new search's objective and `plan_cost` -// differ over terms the MILP encodes inexactly). With the environment -// variable `ASAP_STAGE3_SEARCH=milp`, Stage 3 runs #1095's selection MILP -// instead of the old engine (until it becomes the default); `error` when no +// differ over terms the MILP encodes inexactly). Stage 3 runs #1095's +// selection MILP, which supports cost model 1 only (cost models 2 and 3 +// are an `UnsupportedCostModel` error); `ASAP_STAGE3_SEARCH=old` runs the +// old engine instead; `error` when no // plan was selected. `tumbling_window_retention_ms` maps each pane chain the // selected plan keeps beyond one evaluation (by node id in `P1`) to how // far back the runtime must keep its tumbling windows (`RealizedPlan::tumbling_window_retention_ms`). diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index b384b1607..5fcb168d4 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -2553,8 +2553,8 @@ fn memo_section_lists_each_step_the_selected_choice_and_the_enumeration() { assert!(document.get("memo").is_none()); } -/// `stage_pipeline`'s document with Stage 3's new search on -/// (`ASAP_STAGE3_SEARCH=milp`, #1095 C5), set on the child process only. +/// `stage_pipeline`'s document with Stage 3's new search forced on +/// (`ASAP_STAGE3_SEARCH=milp`, #1095), set on the child process only. fn document_with_milp_search(args: &[&str]) -> Value { static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/plan-selection/src/search/select.rs b/crates/plan-selection/src/search/select.rs index 09f08e481..2ca4a1d22 100644 --- a/crates/plan-selection/src/search/select.rs +++ b/crates/plan-selection/src/search/select.rs @@ -3,8 +3,8 @@ //! refinement loop (cost models 1 and 2 with a frozen calibration) or the //! bounded objective traversal (cost model 3, or a measured cost table //! without a frozen factor). #1095 replaces the engine; this interface stays. -//! With `ASAP_STAGE3_SEARCH=milp` the new search runs instead -//! ([`crate::select`]), until #1095 C6 makes it the default. +//! The group MILP ([`crate::select`]) is the default; this engine runs only +//! with `ASAP_STAGE3_SEARCH=old`. use std::time::{Duration, Instant}; @@ -55,7 +55,7 @@ pub fn select_plan( cost_inputs .validate() .map_err(ErrorSelectingPlan::InvalidSettings)?; - // #1095 C5: the new search, behind its internal switch until C6. + // #1095 C6: the group MILP is the default. if crate::select::milp_search_enabled() { return crate::select::select_plan_with_milp( physical, diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs index 1d7bba0dd..809a257cf 100644 --- a/crates/plan-selection/src/select.rs +++ b/crates/plan-selection/src/select.rs @@ -1,6 +1,8 @@ //! #1095 Steps 3–6: the group selection MILP behind [`select_plan`](crate::select_plan), -//! on while the internal switch [`STAGE3_SEARCH_ENV`] is `milp` (the old -//! engine stays the default until C6; there is no fallback between them). +//! Stage 3's default search (#1095 C6). Inputs it does not price yet (cost +//! models 2 and 3) are an `UnsupportedCostModel` error; `ASAP_STAGE3_SEARCH=old` +//! runs the old engine instead ([`milp_search_enabled`]). There is no +//! fallback between the two engines. //! //! One MILP solve and one realization, no loop: //! 1. the MILP's inputs (candidate dominance, execution contexts, accuracy @@ -48,14 +50,17 @@ use crate::report::{ Selection, }; -/// The environment variable that switches Stage 3 to the new search: -/// `ASAP_STAGE3_SEARCH=milp`. Any other value, or none, keeps the old -/// engine (until #1095 C6 makes the new search the default). +/// The environment variable that switches Stage 3 back to the old engine: +/// `ASAP_STAGE3_SEARCH=old`. Unset, or any other value, runs the group MILP +/// (#1095 C6), which returns `UnsupportedCostModel` for inputs it does not +/// price yet (cost models 2 and 3, or a measured operation cost table +/// without a frozen calibration factor). pub(crate) const STAGE3_SEARCH_ENV: &str = "ASAP_STAGE3_SEARCH"; -/// Whether [`STAGE3_SEARCH_ENV`] turns the new search on. +/// Whether Stage 3 runs the group MILP: always, unless +/// [`STAGE3_SEARCH_ENV`] is `old`. pub(crate) fn milp_search_enabled() -> bool { - std::env::var(STAGE3_SEARCH_ENV).is_ok_and(|value| value == "milp") + std::env::var(STAGE3_SEARCH_ENV).as_deref() != Ok("old") } /// The largest relative difference between the objective and `plan_cost` From b7b4ce35b2962af892519aaadb682e7b6d003ae2 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:04:52 +0000 Subject: [PATCH 9/9] fix(plan-selection): use the remaining tumbling-window names (#1163 part 2) in the group MILP #1176 renamed the remaining pane helpers (pane_candidates, kept_pane_chains, panes_of, retained_panes, the pane test helper, ...); rename their uses in the group MILP's code and tests. --- crates/cost/src/lib.rs | 7 ++++--- crates/plan-selection/src/groups.rs | 10 +++++----- crates/plan-selection/src/groups/tests.rs | 18 ++++++++++++------ crates/plan-selection/src/milp/model/tests.rs | 6 +++--- crates/plan-selection/src/select.rs | 4 ++-- crates/types/tests/materialized_view.rs | 2 +- 6 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/cost/src/lib.rs b/crates/cost/src/lib.rs index 1f6536faf..98e989762 100644 --- a/crates/cost/src/lib.rs +++ b/crates/cost/src/lib.rs @@ -41,9 +41,10 @@ pub use measured::{ pub use operator_statistics::EdgeStatistics; pub use ranking::{cheaper, worst_latency_ms, PlanRanking}; pub use usage::{ - family_name, kept_tumbling_window_chains, maintained_window_ms, node_map, node_usage, tumbling_windows_of, plan_cost, - query_reach, retained_tumbling_windows, trailing_window, tumbling_window_retention_ms, - tumbling_window_retention_ms_of_plan, NodeMap, NodeUsage, ProducerOutput, + family_name, kept_tumbling_window_chains, maintained_window_ms, node_map, node_usage, + plan_cost, query_reach, retained_tumbling_windows, trailing_window, + tumbling_window_retention_ms, tumbling_window_retention_ms_of_plan, tumbling_windows_of, + NodeMap, NodeUsage, ProducerOutput, }; /// Cost per second of wall time; one unit is one CPU-millisecond under diff --git a/crates/plan-selection/src/groups.rs b/crates/plan-selection/src/groups.rs index 375b6d9ed..659ce3b14 100644 --- a/crates/plan-selection/src/groups.rs +++ b/crates/plan-selection/src/groups.rs @@ -31,7 +31,7 @@ //! [`PhysicalASAPDAG::scan_window`] for a plan with that one reader); //! - for a pane chain kept beyond one evaluation, the panes it keeps, which //! grow with the range-evaluation span of the queries reaching it -//! ([`asap_cost::tumbling_window_retention_ms`], [`asap_cost::retained_panes`]). +//! ([`asap_cost::tumbling_window_retention_ms`], [`asap_cost::retained_tumbling_windows`]). //! //! Every combination of these options is costed (at most //! [`MAX_CONTEXTS`]); a combination `asap-cost` cannot cost is skipped @@ -1331,7 +1331,7 @@ fn retained_pane_options( ) ); if !kept { - return vec![asap_cost::panes_of(node)]; + return vec![asap_cost::tumbling_windows_of(node)]; } // The chain over the time shifts below it, which set its input offset. let mut below = Vec::new(); @@ -1362,7 +1362,7 @@ fn retained_pane_options( }; for range in spans { let retention = asap_cost::tumbling_window_retention_ms(&dag, &reach, &[range]); - let panes = asap_cost::retained_panes(node, &retention); + let panes = asap_cost::retained_tumbling_windows(node, &retention); options.push(panes); } options @@ -1637,7 +1637,7 @@ fn candidate_data( picked }; let retained_pick = pick(retained.len()); - let retained_panes = retained[retained_pick]; + let retained_tumbling_windows = retained[retained_pick]; let window_pick = pick(windows.len()); let window = windows[window_pick]; let mut option_picks: Vec = producer_options @@ -1657,7 +1657,7 @@ fn candidate_data( &producers, (1, per_second), window, - retained_panes, + retained_tumbling_windows, ingestion_window_ms, data, cost_inputs, diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index 6a9f6ce36..cb1e1765b 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use super::*; -use asap_logical_asap_optimization::PaneWidthDomain; +use asap_logical_asap_optimization::TumblingWindowWidthDomain; use asap_types::ir::properties::{AccuracyMetric, BoundExpr, ProbabilityExpr}; use asap_types::ir::scalar::expr_ir::ScalarValue; use asap_types::ir::schema::SketchAlgorithm; @@ -547,11 +547,14 @@ struct ThroughStages1And2 { /// through Stage 1 and Stage 2 as the facade runs them, over the complete pane-width domain, with raw data and /// query-time results retained. fn through_stages_1_and_2(queries: &[&str]) -> ThroughStages1And2 { - through_stages_1_and_2_over(queries, PaneWidthDomain::Complete) + through_stages_1_and_2_over(queries, TumblingWindowWidthDomain::Complete) } /// [`through_stages_1_and_2`] over the pane-width domain `domain`. -fn through_stages_1_and_2_over(queries: &[&str], domain: PaneWidthDomain) -> ThroughStages1And2 { +fn through_stages_1_and_2_over( + queries: &[&str], + domain: TumblingWindowWidthDomain, +) -> ThroughStages1And2 { use asap_logical_asap_optimization::test_support::lower_promql; let roots: Vec = queries .iter() @@ -566,7 +569,10 @@ fn target() -> asap_types::ir::properties::AccuracyTarget { } /// [`through_stages_1_and_2_over`] for query roots `roots`. -fn through_stages_1_and_2_of(roots: Vec, domain: PaneWidthDomain) -> ThroughStages1And2 { +fn through_stages_1_and_2_of( + roots: Vec, + domain: TumblingWindowWidthDomain, +) -> ThroughStages1And2 { use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; use asap_logical_asap_optimization::test_support::entry_with; use asap_logical_asap_optimization::{generate_logical_asap_memo_candidates, Pass2RoundState}; @@ -783,7 +789,7 @@ fn one_pane_chain_serves_a_ratio_and_its_operands() { "quantile_over_time(0.9, m[5m])", "quantile_over_time(0.5, m[5m])", ], - PaneWidthDomain::SeedGrids, + TumblingWindowWidthDomain::SeedGrids, ); let inputs = prepare_milp_inputs( &stages.physical, @@ -888,7 +894,7 @@ fn kept_builds_match_fresh_builds(queries: &[&str]) -> MilpInputs { /// [`kept_builds_match_fresh_builds`] for query roots `roots`. fn kept_builds_match_fresh_builds_of(roots: Vec) -> MilpInputs { - let stages = through_stages_1_and_2_of(roots, PaneWidthDomain::SeedGrids); + let stages = through_stages_1_and_2_of(roots, TumblingWindowWidthDomain::SeedGrids); let prepare = |fresh: bool| { accuracy_positions::FRESH_BUILDS.with(|cell| cell.set(fresh)); let inputs = prepare_milp_inputs( diff --git a/crates/plan-selection/src/milp/model/tests.rs b/crates/plan-selection/src/milp/model/tests.rs index d7e668c45..de376d437 100644 --- a/crates/plan-selection/src/milp/model/tests.rs +++ b/crates/plan-selection/src/milp/model/tests.rs @@ -88,7 +88,7 @@ fn through_stages_over( ) -> Fixture { use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; - use asap_logical_asap_optimization::PaneWidthDomain; + use asap_logical_asap_optimization::TumblingWindowWidthDomain; use asap_logical_asap_optimization::{generate_logical_asap_memo_candidates, Pass2RoundState}; use asap_types::ir::properties::AccuracyTarget; use asap_types::workload::{ @@ -146,7 +146,7 @@ fn through_stages_over( let logical = generate_logical_asap_memo_candidates( &mut round, &entries, - PaneWidthDomain::Complete, + TumblingWindowWidthDomain::Complete, None, ) .unwrap(); @@ -250,7 +250,7 @@ fn solve_and_check_by( let (plan, _) = plan_cost( plan_dag, &node_map(plan_dag), - &asap_cost::kept_pane_chains(plan_dag), + &asap_cost::kept_tumbling_window_chains(plan_dag), &recurrence, &ranges, &fixture.data, diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs index 809a257cf..6e742e7a1 100644 --- a/crates/plan-selection/src/select.rs +++ b/crates/plan-selection/src/select.rs @@ -27,7 +27,7 @@ use std::collections::BTreeMap; use std::time::Instant; -use asap_cost::{kept_pane_chains, node_map, plan_cost, CostInputs}; +use asap_cost::{kept_tumbling_window_chains, node_map, plan_cost, CostInputs}; use asap_types::ir::{ASAPOp, Operator}; use asap_types::stages::{ AssignmentRealizer, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, @@ -131,7 +131,7 @@ pub(crate) fn select_plan_with_milp( let (cost, _) = plan_cost( &plan.dag, &node_map(&plan.dag), - &kept_pane_chains(&plan.dag), + &kept_tumbling_window_chains(&plan.dag), &recurrence, &ranges, data, diff --git a/crates/types/tests/materialized_view.rs b/crates/types/tests/materialized_view.rs index 3735e6008..ea6f0a664 100644 --- a/crates/types/tests/materialized_view.rs +++ b/crates/types/tests/materialized_view.rs @@ -514,7 +514,7 @@ fn a_node_above_an_unexpanded_chain_has_the_window_of_its_tumbling_windows() { /// operator's inputs. #[test] fn a_view_has_its_refresh_window_on_a_dag_without_edges() { - let view = pane(&scan_m(), 15_000); + let view = tumbling_window(&scan_m(), 15_000); let mut assignment = MaterializationAssignment::default(); assignment.set(&view, Materialization::MatAtIngestion(Medium::Memory)); let timed = apply_materialization_timings(&view, &assignment, &mut NodeToTimedNodeCache::new())