diff --git a/Cargo.lock b/Cargo.lock index 461791389..0d0aba83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,7 @@ dependencies = [ "asap-physical-asap-optimization", "asap-types", "asap_sketchlib", + "good_lp", "serde", "serde_json", "thiserror 2.0.18", @@ -570,6 +571,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -683,6 +704,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -727,6 +757,26 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "comfy-table" version = "7.2.2" @@ -919,7 +969,7 @@ dependencies = [ "flate2", "futures", "indexmap", - "itertools", + "itertools 0.14.0", "liblzma", "log", "object_store", @@ -951,7 +1001,7 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "itertools", + "itertools 0.14.0", "log", "object_store", "parking_lot", @@ -976,7 +1026,7 @@ dependencies = [ "datafusion-physical-expr-common", "datafusion-physical-plan", "futures", - "itertools", + "itertools 0.14.0", "log", "object_store", ] @@ -995,7 +1045,7 @@ dependencies = [ "half", "hashbrown 0.17.1", "indexmap", - "itertools", + "itertools 0.14.0", "libc", "log", "object_store", @@ -1042,7 +1092,7 @@ dependencies = [ "flate2", "futures", "glob", - "itertools", + "itertools 0.14.0", "liblzma", "log", "object_store", @@ -1073,7 +1123,7 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "itertools", + "itertools 0.14.0", "object_store", "tokio", ] @@ -1147,7 +1197,7 @@ dependencies = [ "datafusion-pruning", "datafusion-session", "futures", - "itertools", + "itertools 0.14.0", "log", "object_store", "parking_lot", @@ -1200,7 +1250,7 @@ dependencies = [ "datafusion-functions-window-common", "datafusion-physical-expr-common", "indexmap", - "itertools", + "itertools 0.14.0", "recursive", "serde_json", "sqlparser", @@ -1215,7 +1265,7 @@ dependencies = [ "arrow", "datafusion-common", "indexmap", - "itertools", + "itertools 0.14.0", ] [[package]] @@ -1239,7 +1289,7 @@ dependencies = [ "datafusion-macros", "datafusion-physical-expr-common", "hex", - "itertools", + "itertools 0.14.0", "log", "md-5", "memchr", @@ -1302,7 +1352,7 @@ dependencies = [ "datafusion-macros", "datafusion-physical-expr-common", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "itoa", "log", "memchr", @@ -1375,7 +1425,7 @@ dependencies = [ "datafusion-expr-common", "datafusion-physical-expr", "indexmap", - "itertools", + "itertools 0.14.0", "log", "recursive", "regex", @@ -1397,7 +1447,7 @@ dependencies = [ "half", "hashbrown 0.17.1", "indexmap", - "itertools", + "itertools 0.14.0", "parking_lot", "petgraph", "recursive", @@ -1416,7 +1466,7 @@ dependencies = [ "datafusion-functions", "datafusion-physical-expr", "datafusion-physical-expr-common", - "itertools", + "itertools 0.14.0", ] [[package]] @@ -1431,7 +1481,7 @@ dependencies = [ "datafusion-expr-common", "hashbrown 0.17.1", "indexmap", - "itertools", + "itertools 0.14.0", "parking_lot", "pin-project", ] @@ -1451,7 +1501,7 @@ dependencies = [ "datafusion-physical-expr-common", "datafusion-physical-plan", "datafusion-pruning", - "itertools", + "itertools 0.14.0", "recursive", ] @@ -1480,7 +1530,7 @@ dependencies = [ "half", "hashbrown 0.17.1", "indexmap", - "itertools", + "itertools 0.14.0", "log", "num-traits", "parking_lot", @@ -1835,6 +1885,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "good_lp" +version = "1.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4255eb1d65eea322f73a326038f2bcec19dae13ca50fa557da19b03274b4d56" +dependencies = [ + "fnv", + "highs", +] + [[package]] name = "half" version = "2.7.1" @@ -1885,6 +1945,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "highs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc791427e78fdc1a0a37983f79c20f86a0b7b7dfdb5d6a57a6f72d77cd9074" +dependencies = [ + "highs-sys", + "log", +] + +[[package]] +name = "highs-sys" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4da0c52322648e2360a5a8896d23301fa5ed99ed7607c5faf229bd0bbb30d3f" +dependencies = [ + "bindgen", + "cmake", +] + [[package]] name = "http" version = "1.5.0" @@ -2061,6 +2141,15 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2179,6 +2268,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "liblzma" version = "0.4.8" @@ -2373,6 +2472,12 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2383,6 +2488,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2459,7 +2574,7 @@ dependencies = [ "futures-util", "http", "humantime", - "itertools", + "itertools 0.14.0", "parking_lot", "percent-encoding", "thiserror 2.0.18", @@ -2879,6 +2994,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 24170cc74..79307e8b7 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -38,6 +38,15 @@ // `"default"`. `--exhaustive` instead composes and prices every candidate // (the search the default one is tested against, for small workloads and // comparison) and the document lists them; `search` is `"exhaustive"`. +// `--search legacy|memo-brute-force|memo-milp` picks the default search's +// algorithm (`SearchAlgorithm`): `legacy` (the default, `search` is +// `"default"`), the memo over logical sub-expressions searched by brute +// force (the reference), or solved as a MILP; `search` names it, and the +// search report says which ran (`algorithm`, omitted for legacy) and, +// when the legacy search ran instead, why (`routed_to_legacy`). Not with +// `--exhaustive`. `--milp-time-limit-s ` limits the MILP search: at +// the limit it selects its best plan so far and reports +// `time_limit_reached` with the gap to its bound. // Unlike `plan_installable`'s default settings, Stage 3 ignores accuracy // guarantees unless `--enforce-accuracy-guarantees` is given (below); // `accuracy_guarantees` is `"ignored"` or `"enforced"`. @@ -244,8 +253,8 @@ use asap_logical_asap_optimization::per_computation_candidates::{ use asap_logical_asap_optimization::DeploymentCapabilities; use asap_plan_selection::{ large_peak_memory_warning, ClusterLimits, ClusterObjective, CostModel, MeasuredOperationCosts, - PlanSelectionSettings, PlanningModels, Rejection, ResourceUnitCost, ResourceUsage, Selection, - SelectionError, Stage1ToStage3Search, + PlanSelectionSettings, PlanningModels, Rejection, ResourceUnitCost, ResourceUsage, + SearchAlgorithm, Selection, SelectionError, Stage1ToStage3Search, }; use asap_planner::{installable_plan_of, PlannerExecutorCompileCheck}; use asap_types::ir::flat::{flatten, FlatDag}; @@ -264,7 +273,7 @@ const USAGE: &str = [--name ]... [--table ...] [--max-error --failure-probability --accuracy-metric ] [--sample-interval-ms ] [--series-count ] [--rows-per-second ] [--rows-held ])) \ [--cost-model usage-over-time|aws-fargate-us-east-1|provisioned-capacity|cluster-capacity [--cluster-memory-bytes \ --cluster-disk-bytes [--cluster-cpu-cores ] --cluster-objective lowest-latency|least-memory|pareto-front]] \ -[--plan-selection-settings ] [--enforce-accuracy-guarantees] [--deployment-capabilities ] [--exhaustive [--max-combinations ] | --search legacy|memo-brute-force] [--max-displayed ] --out \n\ +[--plan-selection-settings ] [--enforce-accuracy-guarantees] [--deployment-capabilities ] [--exhaustive [--max-combinations ] | --search legacy|memo-brute-force|memo-milp [--milp-time-limit-s ]] [--max-displayed ] --out \n\ --search legacy is the default search (the document's \"search\" is \"default\")."; /// The default `--max-combinations`: the most Stage 1 candidates (Pass 2's @@ -370,9 +379,10 @@ fn run(args: Vec) -> Result<(), Failure> { let mut plan_selection_settings_path: Option = None; let mut enforce_accuracy_guarantees = false; let mut exhaustive = false; - // `--search memo-brute-force`: the reference search over the memo of - // sub-expressions instead of the default search. - let mut memo_brute_force = false; + // `--search`: the default search's algorithm, legacy unless given; + // `--milp-time-limit-s` limits the MILP search. + let mut search_algorithm: Option = None; + let mut milp_time_limit_ms: Option = None; let mut max_combinations_given = false; let mut args = args.into_iter(); while let Some(flag) = args.next() { @@ -436,11 +446,23 @@ fn run(args: Vec) -> Result<(), Failure> { "--enforce-accuracy-guarantees" => enforce_accuracy_guarantees = true, "--exhaustive" => exhaustive = true, "--search" => { - memo_brute_force = match value()?.as_str() { - "legacy" => false, - "memo-brute-force" => true, - other => return Err(format!("unknown search {other}").into()), - } + search_algorithm = Some(match value()?.as_str() { + "legacy" => SearchAlgorithm::Legacy, + "memo-brute-force" => SearchAlgorithm::MemoBruteForce, + "memo-milp" => SearchAlgorithm::MemoMilp, + other => { + return Err(format!( + "--search {other}: expected legacy, memo-brute-force or memo-milp" + ) + .into()) + } + }) + } + "--milp-time-limit-s" => { + let seconds: f64 = value()? + .parse() + .map_err(|e| format!("--milp-time-limit-s: {e}"))?; + milp_time_limit_ms = Some((seconds * 1e3) as u64); } other => return Err(format!("unknown argument {other}").into()), } @@ -464,8 +486,16 @@ fn run(args: Vec) -> Result<(), Failure> { if max_combinations_given && !exhaustive { return Err("--max-combinations limits the exhaustive search: it needs --exhaustive (the default search has no limit)".into()); } - if exhaustive && memo_brute_force { - return Err("--exhaustive and --search memo-brute-force are two searches: give one".into()); + if exhaustive && search_algorithm.is_some() { + return Err( + "--search picks the default search's algorithm: drop it with --exhaustive".into(), + ); + } + let search_algorithm = search_algorithm.unwrap_or_default(); + if milp_time_limit_ms.is_some() && search_algorithm != SearchAlgorithm::MemoMilp { + return Err( + "--milp-time-limit-s limits the MILP search: it needs --search memo-milp".into(), + ); } if plan_selection_settings_path.is_some() && (cost_model.is_some() @@ -692,8 +722,8 @@ fn run(args: Vec) -> Result<(), Failure> { &data, models, Stage1ToStage3Search { - memo_groups: memo_brute_force, - ..Stage1ToStage3Search::default() + milp_time_limit_ms, + ..Stage1ToStage3Search::with_algorithm(search_algorithm) }, ), }; @@ -719,10 +749,11 @@ fn run(args: Vec) -> Result<(), Failure> { } document["workload"]["data_workload"] = serde_json::to_value(&data).map_err(|e| e.to_string())?; - document["search"] = json!(match (exhaustive, memo_brute_force) { + document["search"] = json!(match (exhaustive, search_algorithm) { (true, _) => "exhaustive", - (false, true) => "memo-brute-force", - (false, false) => "default", + (false, SearchAlgorithm::Legacy) => "default", + (false, SearchAlgorithm::MemoBruteForce) => "memo-brute-force", + (false, SearchAlgorithm::MemoMilp) => "memo-milp", }); document["accuracy_guarantees"] = json!(if enforce_accuracy_guarantees { "enforced" diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index c519a322a..9419906de 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -236,8 +236,8 @@ pub fn run_default_search_over( ) } -/// [`run_default_search_over`] with the search `search` (e.g. the -/// reference search over the memo, `memo_groups`). +/// [`run_default_search_over`] with the search `search` (e.g. the memo +/// over logical sub-expressions searched as a MILP, with a time limit). pub fn run_search_over( roots: Vec, entries: &[QueryWorkloadEntry], diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index d6c411691..56dca67cd 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -1706,6 +1706,118 @@ fn scans_projecting_different_labels_are_shared() { ); } +/// `--search` picks the default search's algorithm. Example 3b's p99 panel +/// under cost model 1: the memo searched by brute force selects a plan of +/// the legacy search's price; the MILP, not built yet, runs the legacy +/// search and says why; under cost model 2 the MILP runs the legacy search +/// because it prices cost model 1 only. +#[test] +fn search_flag_picks_the_search_algorithm() { + let run = |extra: &[&str]| { + let mut args = vec!["--example", "planner-layering-3b"]; + args.extend_from_slice(extra); + document_of(&args) + }; + let price = |document: &Value| { + let selection = &document["stage3_plan_selection"]; + let selected = selection["selected"].as_str().unwrap(); + selection["costs"][selected]["cost_per_second"] + .as_f64() + .unwrap() + }; + let legacy = run(&["--search", "legacy"]); + assert_eq!(legacy["search"], "default"); + let report = &legacy["stage3_plan_selection"]["search"]; + // The legacy search's report omits the algorithm and the MILP's + // report; every search reports its selected plan's cost. + for field in ["algorithm", "routed_to_legacy", "milp"] { + assert!(report.get(field).is_none(), "{field}: {report}"); + } + assert_eq!(report["incumbent_cost"].as_f64(), Some(price(&legacy))); + + let brute_force = run(&["--search", "memo-brute-force"]); + assert_eq!(brute_force["search"], "memo-brute-force"); + let report = &brute_force["stage3_plan_selection"]["search"]; + assert_eq!(report["algorithm"], "memo-brute-force"); + assert!((price(&brute_force) - price(&legacy)).abs() <= 1e-9 * price(&legacy)); + + let milp = run(&["--search", "memo-milp"]); + assert_eq!(milp["search"], "memo-milp"); + let report = &milp["stage3_plan_selection"]["search"]; + assert_eq!(report["algorithm"], "memo-milp"); + assert!(report.get("routed_to_legacy").is_none(), "{report}"); + let milp_report = &report["milp"]; + assert!(milp_report["rounds"].as_u64().unwrap() >= 1, "{report}"); + assert!(milp_report["gap"].as_f64().unwrap() <= 1e-9, "{report}"); + assert!( + milp_report["lower_bound"].as_f64().unwrap() <= price(&milp) * (1.0 + 1e-9), + "{report}" + ); + assert_eq!(milp_report["time_limit_reached"], false, "{report}"); + assert!((price(&milp) - price(&legacy)).abs() <= 1e-9 * price(&legacy)); + + let cost_model_2 = run(&[ + "--search", + "memo-milp", + "--cost-model", + "provisioned-capacity", + ]); + assert_eq!( + cost_model_2["stage3_plan_selection"]["search"]["routed_to_legacy"], + "cost_model_not_supported" + ); +} + +/// `--search` takes one of its three algorithms, and not with +/// `--exhaustive`, which is its own search. +#[test] +fn search_flag_is_checked() { + let (status, stderr) = failure(&["--promql", "sum(m)", "--search", "milp"]); + assert_eq!(status, Some(2)); + assert!( + stderr.contains("expected legacy, memo-brute-force or memo-milp"), + "{stderr}" + ); + let (status, stderr) = failure(&[ + "--promql", + "sum(m)", + "--exhaustive", + "--search", + "memo-milp", + ]); + assert_eq!(status, Some(2)); + assert!(stderr.contains("drop it with --exhaustive"), "{stderr}"); +} + +/// The MILP search stopped by its time limit before any valid plan says +/// so (never a plan called optimal), and the limit needs the MILP search. +#[test] +fn milp_time_limit_is_reported_and_needs_the_milp_search() { + let document = document_of(&[ + "--example", + "planner-layering-3b", + "--search", + "memo-milp", + "--milp-time-limit-s", + "0", + ]); + let selection = &document["stage3_plan_selection"]; + assert!(selection["selected"].is_null(), "{selection}"); + assert!( + selection["error"] + .as_str() + .is_some_and(|e| e.contains("reached its time limit")), + "{selection}" + ); + let (_, stderr) = failure(&[ + "--example", + "planner-layering-3b", + "--milp-time-limit-s", + "1", + ]); + assert!(stderr.contains("needs --search memo-milp"), "{stderr}"); +} + /// The memo search's document names its selected plan's logical candidate /// under the id its physical candidates read (`from_logical`), as the /// viewer's validator requires: the brute force numbers its logical plans, @@ -1743,20 +1855,6 @@ fn the_memo_search_document_names_the_selected_logical_candidate() { assert!(physical.iter().any(|c| c["id"] == selected)); } -/// `--exhaustive` and `--search memo-brute-force` are two searches. -#[test] -fn exhaustive_and_the_memo_search_are_not_both_given() { - let (status, stderr) = failure(&[ - "--promql", - "sum(m)", - "--exhaustive", - "--search", - "memo-brute-force", - ]); - assert_eq!(status, Some(2)); - assert!(stderr.contains("give one"), "{stderr}"); -} - /// Each shown candidate's `per_node` entries carry the node's operator, /// timing and predicted CPU, as `plan_installable`'s predicted usage names /// them, and the candidate's `cpu_core_seconds_per_second` is their sum: @@ -1883,9 +1981,13 @@ fn mixed_language_workload_files(sql_arrival: Value, sql_facts: Value) -> (PathB }, "data_workload": {"arrival": sql_arrival, "sources": {"flows": sql_facts}}, }); + // Tests run as threads of one process, so the names need a per-call + // counter. + static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = |name: &str| { std::env::temp_dir().join(format!( - "stage_pipeline_{}_mixed_{name}.json", + "stage_pipeline_{}_{call}_mixed_{name}.json", std::process::id() )) }; diff --git a/crates/integration-tests/tests/joint_search_equivalence.rs b/crates/integration-tests/tests/joint_search_equivalence.rs index 8174cf5cf..2baf63a3f 100644 --- a/crates/integration-tests/tests/joint_search_equivalence.rs +++ b/crates/integration-tests/tests/joint_search_equivalence.rs @@ -10,7 +10,8 @@ use asap_devtools::planning_stages::{lower_promql_roots, lower_sql_roots}; use asap_plan_selection::{ run_stage1_to_stage3, run_stage1_to_stage3_with, AccuracyModel, BuiltinAccuracyModel, ClusterLimits, ClusterObjective, CostModel, PlanSelectionSettings, PlanningModels, - RejectionCause, ResourceUnitCost, SelectionError, Stage1ToStage3Run, Stage1ToStage3Search, + RejectionCause, ResourceUnitCost, RoutedToLegacy, SearchAlgorithm, SelectionError, + Stage1ToStage3Run, Stage1ToStage3Search, }; use asap_types::ir::flat::flatten; use asap_types::ir::properties::{AccuracyGuarantee, AccuracyMetric, AccuracyTarget}; @@ -503,7 +504,8 @@ fn a_known_bound_that_misses_is_not_discarded_before_composition() { Stage1ToStage3Search { keep_every_candidate: true, exhaustive: false, - memo_groups: false, + search: SearchAlgorithm::Legacy, + milp_time_limit_ms: None, }, ) .unwrap(); @@ -1203,10 +1205,7 @@ fn memo_group_search_selects_the_exhaustive_price() { &entries, &data, models, - Stage1ToStage3Search { - memo_groups: true, - ..Stage1ToStage3Search::default() - }, + Stage1ToStage3Search::with_algorithm(SearchAlgorithm::MemoBruteForce), ) .unwrap(); let price = |run: &Stage1ToStage3Run| { @@ -1242,6 +1241,512 @@ fn memo_group_search_selects_the_exhaustive_price() { assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); } +/// Debugging aid: the memo's and the exhaustive search's selected roots on +/// one seed. +#[test] +#[ignore = "debugging aid"] +fn debug_memo_seed() { + use asap_types::ir::{ASAPOp, NonASAPOp, OperatorNode}; + fn show(node: &std::rc::Rc, depth: usize, out: &mut String) { + let detail = match (node.asap(), node.non_asap()) { + ( + Some(ASAPOp::SummaryAgg { + family, reduction, .. + }), + _, + ) => { + format!("{family:?} {reduction:?}") + } + ( + Some(ASAPOp::SummaryMerge { + newest_panes, + group_by, + .. + }), + _, + ) => { + format!("newest {newest_panes:?} group_by {group_by:?}") + } + ( + Some(ASAPOp::PaneChain { + family, + reduction, + pane_width_ms, + pane_count, + .. + }), + _, + ) => { + format!("{family:?} {reduction:?} {pane_width_ms}x{pane_count}") + } + (_, Some(NonASAPOp::TimeRange { range, .. })) => format!("{range:?}"), + ( + _, + Some(NonASAPOp::Aggregate { + reduction, + measures, + .. + }), + ) => { + format!("{reduction:?} {measures:?}") + } + _ => String::new(), + }; + let detail: String = detail.chars().take(160).collect(); + out.push_str(&format!( + "{}{} {:p} {detail}\n", + " ".repeat(depth), + node.operator.kind_name(), + std::rc::Rc::as_ptr(node) + )); + for child in node.children() { + show(child, depth + 1, out); + } + } + let seed: u64 = std::env::var("SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(59); + let gauges: &[&str] = match seed % 3 { + 0 => &["m", "n"], + _ => &["m"], + }; + let workload = random_workload_over(seed, gauges); + let roots = lower_promql_roots(&workload).unwrap(); + let entries: Vec<_> = workload.query_workload.entries().collect(); + for e in &entries { + eprintln!("{} {:?}", e.query.0, e.recurrence); + } + let data = workload.data_workload.clone().unwrap(); + let deployment = match seed % 2 { + 0 => no_raw_data(), + _ => asap_logical_asap_optimization::DeploymentCapabilities::default(), + }; + let models = PlanningModels::builtin().with_deployment_capabilities(&deployment); + let indexed = || roots.iter().cloned().enumerate().collect::>(); + for (name, search) in [ + ("exhaustive", Stage1ToStage3Search::exhaustive()), + ( + "memo", + Stage1ToStage3Search::with_algorithm(SearchAlgorithm::MemoBruteForce), + ), + ] { + let run = run_stage1_to_stage3_with(indexed(), &entries, &data, models, search).unwrap(); + let s = run.stage3_plan_selection.as_ref().unwrap(); + eprintln!("== {name}: {:?}", s.costs[&s.selected].cost_per_second); + let mut out = String::new(); + for root in run + .stage1_selected + .as_ref() + .unwrap() + .roots + .as_ref() + .unwrap() + { + if let QueryRoot::Operator(node) = root { + show(node, 0, &mut out); + } + out.push_str("--\n"); + } + eprintln!("{out}"); + } +} + +/// What a search's selection is compared by: under cost models 1 and 2 +/// its price per second; under cost model 3 the key its objective ranks +/// by (worst-query latency in ms, peak memory and disk in bytes, in the +/// objective's order; the Pareto front selects its lowest-latency +/// member). `None` when no candidate is valid. +fn objective_of(run: &Stage1ToStage3Run, cost_model: &CostModel) -> Option> { + let selection = run.stage3_plan_selection.as_ref().ok()?; + let cost = &selection.costs[&selection.selected]; + let usage = &cost.usage; + let worst_latency_ms = usage + .latency_per_query + .iter() + .map(|l| l.total_ms()) + .fold(0.0, f64::max); + let (memory, disk) = (usage.peak_memory_bytes as f64, usage.disk_bytes as f64); + Some(match cost_model { + CostModel::ClusterCapacity(limits) => match limits.objective { + ClusterObjective::LeastMemory => vec![memory, disk, worst_latency_ms], + ClusterObjective::LowestLatency | ClusterObjective::ParetoFront => { + vec![worst_latency_ms, memory, disk] + } + }, + _ => vec![cost + .cost_per_second + .expect("cost models 1 and 2 price every valid candidate")], + }) +} + +/// Whether `a` and `b` are equal within 1e-9, relative (absolute for 0). +fn same_objective(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => a + .iter() + .zip(b) + .all(|(a, b)| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)), + (None, None) => true, + _ => false, + } +} + +/// `roots` planned three ways under `models`: the exhaustive search, the +/// memo searched by brute force ([`SearchAlgorithm::MemoBruteForce`]) and +/// the memo solved as a MILP ([`SearchAlgorithm::MemoMilp`]). The +/// exhaustive search and the brute force select plans of the same price +/// (within 1e-9; [`objective_of`] under cost model 3), or both find none. +/// While the MILP runs the legacy search instead (it prices cost model 1 +/// only, and is not built yet), the report says why and only the first two +/// are compared; once it runs, it selects that price too, with a solver gap +/// of 0. Returns whether the MILP ran. +fn assert_three_way( + label: &str, + roots: &[QueryRoot], + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, +) -> bool { + three_way(label, roots, entries, data, models).unwrap_or_else(|mismatch| panic!("{mismatch}")) +} + +/// [`assert_three_way`], with a mismatch returned rather than panicking. +fn three_way( + label: &str, + roots: &[QueryRoot], + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, +) -> Result { + let run = |search| { + run_stage1_to_stage3_with( + roots.iter().cloned().enumerate().collect(), + entries, + data, + models, + search, + ) + .unwrap_or_else(|e| panic!("{label}: {e}")) + }; + let cost_model = &models.plan_selection.cost_model; + let exhaustive = objective_of(&run(Stage1ToStage3Search::exhaustive()), cost_model); + let brute_force_run = run(Stage1ToStage3Search::with_algorithm( + SearchAlgorithm::MemoBruteForce, + )); + if let Ok(selection) = &brute_force_run.stage3_plan_selection { + assert_eq!( + selection.search.algorithm, + SearchAlgorithm::MemoBruteForce, + "{label}" + ); + } + let brute_force = objective_of(&brute_force_run, cost_model); + if !same_objective(&exhaustive, &brute_force) { + return Err(format!( + "{label}: exhaustive {exhaustive:?}, memo brute force {brute_force:?}" + )); + } + let milp_run = run(Stage1ToStage3Search::with_algorithm( + SearchAlgorithm::MemoMilp, + )); + let Ok(selection) = &milp_run.stage3_plan_selection else { + // The legacy search or the MILP found no valid plan. + return match exhaustive { + None => Ok(false), + Some(_) => Err(format!( + "{label}: exhaustive {exhaustive:?}, memo MILP {:?}", + milp_run.stage3_plan_selection + )), + }; + }; + let report = &selection.search; + if report.algorithm == SearchAlgorithm::Legacy { + assert!( + !matches!(cost_model, CostModel::UsageOverTime(_)), + "{label}: cost model 1 is searched by the MILP" + ); + assert_eq!( + report.routed_to_legacy, + Some(RoutedToLegacy::CostModelNotSupported), + "{label}" + ); + return Ok(false); + } + assert_eq!(report.algorithm, SearchAlgorithm::MemoMilp, "{label}"); + assert_eq!(report.routed_to_legacy, None, "{label}"); + let milp = objective_of(&milp_run, cost_model); + if !same_objective(&exhaustive, &milp) { + return Err(format!( + "{label}: exhaustive {exhaustive:?}, memo MILP {milp:?}" + )); + } + let gap = report + .milp + .as_ref() + .and_then(|milp| milp.gap) + .expect("the MILP reports its gap"); + if gap.abs() > 1e-9 { + return Err(format!("{label}: MILP gap {gap}")); + } + Ok(true) +} + +/// [`assert_three_way`] on seeds 1 to 60 of the random workloads small +/// enough to search exhaustively, over one or two gauges, with raw data +/// kept or not, under each of `settings` in turn (every one with `every`). +fn assert_three_way_on_random_workloads(settings: &[PlanSelectionSettings], every: bool) { + assert_three_way_on_seeds(settings, every, 60, 20); +} + +/// The random workload of `seed`, over one gauge or (every third seed) +/// two. +fn seeded_workload(seed: u64) -> PlanningWorkload { + let gauges: &[&str] = match seed % 3 { + 0 => &["m", "n"], + _ => &["m"], + }; + random_workload_over(seed, gauges) +} + +/// The number of whole-workload combinations of `workload`'s candidates. +fn combinations_of(workload: &PlanningWorkload) -> usize { + let roots = lower_promql_roots(workload).unwrap(); + asap_logical_asap_optimization::per_computation_candidates::generate_per_computation_candidates( + roots.iter().cloned().enumerate().collect(), + ) + .unwrap() + .combinations() +} + +/// [`assert_three_way_on_random_workloads`] on the `count` seeds of 1 to +/// 60 with the fewest combinations (all of them small enough to search +/// exhaustively), at least `at_least` of them. +fn assert_three_way_on_seeds( + settings: &[PlanSelectionSettings], + every: bool, + count: usize, + at_least: usize, +) { + let mut seeds: Vec<(usize, u64)> = (1..=60u64) + .map(|seed| (combinations_of(&seeded_workload(seed)), seed)) + .filter(|&(combinations, _)| combinations <= MAX_EXHAUSTIVE_COMBINATIONS) + .collect(); + seeds.sort_unstable(); + seeds.truncate(count); + let mut searched = 0; + let mut mismatches = Vec::new(); + for (_, seed) in seeds { + let workload = seeded_workload(seed); + let roots = lower_promql_roots(&workload).unwrap(); + searched += 1; + let entries: Vec<_> = workload.query_workload.entries().collect(); + let data = workload.data_workload.clone().unwrap(); + let deployment = match seed % 2 { + 0 => no_raw_data(), + _ => asap_logical_asap_optimization::DeploymentCapabilities::default(), + }; + let turn = seed as usize % settings.len(); + let chosen = match every { + true => settings, + false => &settings[turn..=turn], + }; + for settings in chosen { + let models = PlanningModels::builtin() + .with_plan_selection(settings) + .with_deployment_capabilities(&deployment); + if let Err(mismatch) = three_way( + &format!("seed {seed} under {:?}", settings.cost_model), + &roots, + &entries, + &data, + models, + ) { + mismatches.push(mismatch); + } + } + } + assert!( + searched >= at_least, + "only {searched} workloads were small enough" + ); + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +/// The three searches agree on the random workloads under cost model 1. +#[test] +#[ignore = "the exhaustive search and the brute force over every seed take minutes in a debug build; run with --release --ignored"] +fn three_way_random_workloads_under_cost_model_1() { + assert_three_way_on_random_workloads(&cost_models(1)[..1], true); +} + +/// The three searches agree under cost model 1 on the 20 random workloads +/// with the fewest combinations: the quick subset of +/// [`three_way_random_workloads_under_cost_model_1`]. +#[test] +#[ignore = "the exhaustive search over 20 seeds takes a minute or more in a debug build; run with --release --ignored"] +fn three_way_twenty_smallest_random_workloads_under_cost_model_1() { + assert_three_way_on_seeds(&cost_models(1)[..1], true, 20, 20); +} + +/// The three searches agree on the random workloads under cost model 2. +#[test] +#[ignore = "the exhaustive search and the brute force over every seed under cost model 2 take minutes in a debug build; run with --release --ignored"] +fn three_way_random_workloads_under_cost_model_2() { + assert_three_way_on_random_workloads(&cost_models(1)[1..2], true); +} + +/// The three searches agree on the random workloads under cost model 3, +/// each objective in turn, with 1 GB of cluster memory. +#[test] +#[ignore = "the exhaustive search and the brute force over every seed under cost model 3 take minutes in a debug build; run with --release --ignored"] +fn three_way_random_workloads_under_cost_model_3() { + assert_three_way_on_random_workloads(&cost_models(1)[2..], false); +} + +/// `queries`, each repeated every minute within 5% error, over the gauge +/// `m` with `rows_per_second` series each sampled every second. +fn workload_at_rate(queries: &[&str], rows_per_second: u64) -> PlanningWorkload { + let queries: Vec<(&str, u32)> = queries.iter().map(|&q| (q, 60_000)).collect(); + let mut workload = workload_of(&queries, AccuracyTarget::max_error(0.05)); + let data = workload.data_workload.as_mut().unwrap(); + data.arrival = DataArrival::Ingesting { + rows_per_second: rows_per_second as f64, + }; + for facts in data.sources.values_mut() { + facts.series_count = Some(rows_per_second); + facts.sample_interval = Some(DurationMs(1_000)); + } + workload +} + +/// `settings` with `cost_model`. +fn under(cost_model: CostModel) -> PlanSelectionSettings { + PlanSelectionSettings { + cost_model, + ..PlanSelectionSettings::default() + } +} + +/// The exhaustive search's selected candidate's usage under `settings`. +fn exhaustive_selection( + workload: &PlanningWorkload, + settings: &PlanSelectionSettings, +) -> asap_plan_selection::ResourceUsage { + let roots = lower_promql_roots(workload).unwrap(); + let entries: Vec<_> = workload.query_workload.entries().collect(); + let data = workload.data_workload.clone().unwrap(); + let run = run_stage1_to_stage3_with( + roots.into_iter().enumerate().collect(), + &entries, + &data, + PlanningModels::builtin().with_plan_selection(settings), + Stage1ToStage3Search::exhaustive(), + ) + .unwrap(); + let selection = run.stage3_plan_selection.unwrap(); + selection.costs[&selection.selected].usage.clone() +} + +/// [`assert_three_way`] on `workload` under `settings`. +fn assert_three_way_on(label: &str, workload: &PlanningWorkload, settings: &PlanSelectionSettings) { + let roots = lower_promql_roots(workload).unwrap(); + let entries: Vec<_> = workload.query_workload.entries().collect(); + let data = workload.data_workload.clone().unwrap(); + assert_three_way( + label, + &roots, + &entries, + &data, + PlanningModels::builtin().with_plan_selection(settings), + ); +} + +/// A peak case: under cost model 2, a p99 and a sum over 5 minutes of a +/// million rows per second, raw data on disk priced at 1e-7 per byte-second. +/// Scanning the raw window at query time needs one core per query and no +/// ingestion core; maintaining state at ingestion time needs fewer disk +/// bytes and one more core. At 1,000 per core-second the query-time plan is +/// selected; with cores free, a plan with an ingestion core is: the peak +/// cores decide. The three searches agree. +#[test] +fn three_way_peak_cores_decide_under_cost_model_2() { + let workload = workload_at_rate( + &["quantile_over_time(0.99, m[5m])", "sum_over_time(m[5m])"], + 1_000_000, + ); + let prices = |cost_per_provisioned_cpu_core_second| ResourceUnitCost { + cost_per_disk_byte_second: 1e-7, + cost_per_provisioned_cpu_core_second, + ..ResourceUnitCost::illustrative() + }; + let priced = under(CostModel::ProvisionedCapacity(prices(1_000.0))); + let free_cores = under(CostModel::ProvisionedCapacity(prices(0.0))); + let (with_core_price, without) = ( + exhaustive_selection(&workload, &priced), + exhaustive_selection(&workload, &free_cores), + ); + assert!( + with_core_price.peak_cpu_cores < without.peak_cpu_cores, + "{with_core_price:?} {without:?}" + ); + assert_three_way_on("peak cores", &workload, &priced); + assert_three_way_on("peak cores, free", &workload, &free_cores); +} + +/// A capacity case: under cost model 3 with the lowest-latency objective, +/// a p99 and a sum over 5 minutes of a million rows per second. Without a +/// binding limit the lowest-latency plan holds more than 5 GB; with 5 GB +/// of memory it is ruled out and another plan is selected. The three +/// searches agree. +#[test] +fn three_way_memory_limit_rules_out_the_best_plan_under_cost_model_3() { + let workload = workload_at_rate( + &["quantile_over_time(0.99, m[5m])", "sum_over_time(m[5m])"], + 1_000_000, + ); + let limited = |memory_bytes| { + under(CostModel::ClusterCapacity(ClusterLimits { + memory_bytes, + disk_bytes: 1_000_000_000_000, + cpu_cores: None, + objective: ClusterObjective::LowestLatency, + })) + }; + let (unlimited, five_gb) = (limited(1_000_000_000_000_000), limited(5_000_000_000)); + let best = exhaustive_selection(&workload, &unlimited); + assert!(best.peak_memory_bytes > 5_000_000_000, "{best:?}"); + let within = exhaustive_selection(&workload, &five_gb); + assert!(within.peak_memory_bytes <= 5_000_000_000, "{within:?}"); + assert_three_way_on("memory limit", &workload, &five_gb); +} + +/// A raw-horizon case: raw data kept, on disk priced at 1e-8 per +/// byte-second under cost model 1, and a p99 over 5 minutes and a sum over +/// 10 minutes of one source at 100,000 rows per second. Raw data is kept +/// for the longest window any query scans at query time, so the queries' +/// choices are linked through the source's horizon: the selection scans +/// the 5-minute window and keeps no 10-minute horizon. The three searches +/// agree. +#[test] +fn three_way_raw_horizon_links_two_windows_over_one_source() { + let workload = workload_at_rate( + &["quantile_over_time(0.99, m[5m])", "sum_over_time(m[10m])"], + 100_000, + ); + let settings = under(CostModel::UsageOverTime(ResourceUnitCost { + cost_per_disk_byte_second: 1e-8, + ..ResourceUnitCost::illustrative() + })); + let selected = exhaustive_selection(&workload, &settings); + let ten_minutes_of_raw_bytes = 2 * 960_000_000; + assert!( + selected.disk_bytes > 0 && selected.disk_bytes < ten_minutes_of_raw_bytes, + "{selected:?}" + ); + assert_three_way_on("raw horizon", &workload, &settings); +} + /// The memo-group search selects a plan of the exhaustive search's price /// on `workload`, with raw data kept and without (or both find none). fn assert_memo_selects_the_exhaustive_price(label: &str, workload: &PlanningWorkload) { @@ -1270,10 +1775,9 @@ fn assert_memo_selects_the_exhaustive_price(label: &str, workload: &PlanningWork .map(|s| s.costs[&s.selected].cost_per_second.unwrap()) }; let exhaustive = price(&run(Stage1ToStage3Search::exhaustive())); - let memo = price(&run(Stage1ToStage3Search { - memo_groups: true, - ..Stage1ToStage3Search::default() - })); + let memo = price(&run(Stage1ToStage3Search::with_algorithm( + SearchAlgorithm::MemoBruteForce, + ))); match (exhaustive, memo) { (Some(a), Some(f)) => assert!( (a - f).abs() <= 1e-9 * a.abs(), @@ -1338,3 +1842,14 @@ fn a_sketch_rollup_beside_other_quantiles_selects_the_exhaustive_price() { assert_memo_selects_the_exhaustive_price("Q1, Q3, Q4", &workload(&[q1, q3, q4])); assert_memo_selects_the_exhaustive_price("Q2, Q3, Q4", &workload(&[q2, q3, q4])); } + +/// Debugging aid: the random workload of seed `JP_SEED` (over the gauge +/// `m`) as stage_pipeline's `--workload` JSON, at `JP_SEED_OUT`. +#[test] +#[ignore = "debugging aid"] +fn dump_random_workload() { + let seed: u64 = std::env::var("JP_SEED").map_or(43, |s| s.parse().unwrap()); + let out = std::env::var("JP_SEED_OUT").unwrap_or_else(|_| format!("seed{seed}.json")); + let workload = random_workload(seed); + std::fs::write(&out, serde_json::to_string_pretty(&workload).unwrap()).unwrap(); +} diff --git a/crates/plan-selection/Cargo.toml b/crates/plan-selection/Cargo.toml index abb350b64..af7854e82 100644 --- a/crates/plan-selection/Cargo.toml +++ b/crates/plan-selection/Cargo.toml @@ -15,6 +15,8 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +good_lp = { version = "=1.15.3", default-features = false, features = ["highs"] } + [dev-dependencies] asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } diff --git a/crates/plan-selection/src/lib.rs b/crates/plan-selection/src/lib.rs index 8d879be79..76dc9af44 100644 --- a/crates/plan-selection/src/lib.rs +++ b/crates/plan-selection/src/lib.rs @@ -1027,6 +1027,54 @@ pub struct SearchReport { /// the search built none. #[serde(default, skip_serializing_if = "Option::is_none")] pub memo: Option, + /// The search algorithm that ran; omitted in JSON for the legacy + /// search. + #[serde(default, skip_serializing_if = "SearchAlgorithm::is_legacy")] + pub algorithm: SearchAlgorithm, + /// Why the legacy search ran instead of the requested memo search; + /// `None` when the requested algorithm ran. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub routed_to_legacy: Option, + /// The selected plan's cost per second (Stage 3's exact cost); `None` + /// when no plan was selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incumbent_cost: Option, + /// What the MILP search ([`SearchAlgorithm::MemoMilp`]) did; `None` + /// for every other search. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub milp: Option, +} + +/// The MILP search's report: its bound, gap and effort. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MilpReport { + /// The MILP lower bound at the end: a lower bound on the cost per + /// second of every plan the search did not evaluate (the last solver + /// objective, or the incumbent cost once no plan below it is left); + /// `None` when no round finished and no relaxation was solved. + pub lower_bound: Option, + /// The first round's MILP lower bound, before any cut: how tight the + /// model is against the incumbent cost. + pub first_lower_bound: Option, + /// (incumbent cost − lower bound) / incumbent cost, at least 0: 0 when + /// the incumbent is proven optimal over the memo, within the solver's + /// tolerances (a relative 1e-9 in the stopping rule, about 1e-4 when + /// HiGHS stops at its own gap tolerance). + pub gap: Option, + /// Its rounds (each: a solve, the decoded plan's incumbent evaluation, + /// a cut). + pub rounds: u32, + /// The MILP's variables and constraints in its last round. + pub variable_count: u64, + pub constraint_count: u64, + /// The wall time in milliseconds spent in the solver, every round + /// together. + pub solver_time_ms: u64, + /// Whether it stopped at its time limit + /// ([`crate::Stage1ToStage3Search::milp_time_limit_ms`]) or another + /// solver limit (iterations, memory, interrupt): the plan is then + /// proven optimal only if `gap` is 0. + pub time_limit_reached: bool, } /// The size of a sub-expression memo (planner-layering design, "The @@ -1049,6 +1097,44 @@ pub struct MemoStats { pub build_ms: f64, } +/// How the Stage 1 to Stage 3 search explores the plans +/// ([`Stage1ToStage3Search::search`]). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] +pub enum SearchAlgorithm { + /// The search over whole-workload candidates: each query's own + /// candidates checked first, independent components searched + /// separately, only the best so far kept. + #[default] + Legacy, + /// Every plan of the memo over logical sub-expressions, each priced by + /// Stage 3: the reference the MILP is checked against. + MemoBruteForce, + /// The memo over logical sub-expressions solved as a mixed-integer + /// linear program. + MemoMilp, +} + +impl SearchAlgorithm { + fn is_legacy(&self) -> bool { + *self == SearchAlgorithm::Legacy + } +} + +/// Why the legacy search ran instead of the requested memo search. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutedToLegacy { + /// The caller asked for the exhaustive search, which the memo searches + /// do not replace. + ExhaustiveRequested, + /// The MILP's objective is cost model 1's sum of local prices; cost + /// models 2 and 3 are searched by the legacy search. + CostModelNotSupported, +} + /// One independent component of the workload: queries that can read no /// node another component's queries read, nor any source when raw data on /// disk is priced, so no Pass 2 rule, raw-data retention or @@ -1142,6 +1228,9 @@ pub enum SelectionError { }, #[error("no valid candidate: {0:?}")] NoValidCandidate(Vec), + /// The memo search's MILP solver failed. + #[error("MILP solver: {0}")] + Solver(String), } /// Stage 3 (plan selection): reject candidates that the executor cannot @@ -2020,6 +2109,7 @@ impl<'a> PlanSelector<'a> { &query_reach(&sized.dag, &node_map(&sized.dag)), &self.range_evaluations, ); + let incumbent_cost = self.costs.get(&selected).and_then(|c| c.cost_per_second); let selection = Selection { selected, plan, @@ -2031,12 +2121,19 @@ impl<'a> PlanSelector<'a> { search: SearchReport { candidates_checked: self.candidates_checked, runner_up_cost_per_second: self.runner_up, + incumbent_cost, ..SearchReport::default() }, }; Ok((selection, built)) } + /// Under a price ranking, the cheapest price of the valid candidates + /// offered so far. + pub(crate) fn cheapest_price(&self) -> Option { + self.cheapest + } + /// Under a price ranking, fold the price of valid candidate `cost` /// into the cheapest price and the runner-up ([`ROUNDING`]). fn track_runner_up(&mut self, cost: &CandidateCost) { diff --git a/crates/plan-selection/src/pass/stage1_to_stage3.rs b/crates/plan-selection/src/pass/stage1_to_stage3.rs index 56bcf04c1..252a2df35 100644 --- a/crates/plan-selection/src/pass/stage1_to_stage3.rs +++ b/crates/plan-selection/src/pass/stage1_to_stage3.rs @@ -57,8 +57,8 @@ use super::query_candidates::QueryCandidates; use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryPlan}; use crate::PlanningModels; use crate::{ - CalibrationFit, PlanSelector, QuerySearchReport, Rejection, RejectionCause, Selection, - SelectionError, + CalibrationFit, CostModel, PlanSelector, QuerySearchReport, Rejection, RejectionCause, + RoutedToLegacy, SearchAlgorithm, Selection, SelectionError, }; use asap_logical_asap_optimization::per_computation_candidates::{ generate_per_computation_candidates, PerComputationCandidateError, PerComputationCandidates, @@ -159,12 +159,18 @@ pub struct Stage1ToStage3Search { /// own candidates first: the exhaustive search the pruned one is tested /// against; default `false`. pub exhaustive: bool, - /// Search the memo over logical sub-expressions (planner-layering - /// design, "The stages over a memo of sub-expressions") instead of - /// whole-workload candidates; - /// ignored with `exhaustive`. Default `false` until it selects the - /// exhaustive search's price on every equivalence test. - pub memo_groups: bool, + /// The search algorithm (joint-planning-search design: the legacy + /// search, or the memo over logical sub-expressions, "Memo-group + /// search"); ignored with `exhaustive`. Default + /// [`SearchAlgorithm::Legacy`] until the MILP selects the exhaustive + /// search's price on every equivalence test. + pub search: SearchAlgorithm, + /// The longest the MILP search may run, in ms; `None` for no limit. + /// Stopped at the limit, it selects its best plan so far and reports + /// the gap to its bound ([`crate::MilpReport::gap`]) and + /// [`crate::MilpReport::time_limit_reached`]: not proven + /// optimal unless the gap is 0. + pub milp_time_limit_ms: Option, } impl Stage1ToStage3Search { @@ -174,7 +180,16 @@ impl Stage1ToStage3Search { Self { keep_every_candidate: true, exhaustive: true, - memo_groups: false, + search: SearchAlgorithm::Legacy, + milp_time_limit_ms: None, + } + } + + /// The default search run with `algorithm`. + pub fn with_algorithm(algorithm: SearchAlgorithm) -> Self { + Self { + search: algorithm, + ..Self::default() } } } @@ -235,41 +250,104 @@ pub fn run_stage1_to_stage3_from_pass1( models: PlanningModels<'_>, search: Stage1ToStage3Search, ) -> Stage1ToStage3Run { - if search.memo_groups && !search.exhaustive { - let pruned = per_computation - .clone() - .without_unsupported_summaries(models.deployment_capabilities); - let combinations = pruned.combinations(); - let found = super::subexpr::search::brute_force(&pruned, entries, data, models); - let (selection, selected, logical) = match found { - Ok((selection, selected, logical)) => (Ok(selection), Some(selected), Some(logical)), - Err(error) => (Err(error), None, None), - }; - let from_logical = selected.as_ref().map(|s| s.from_logical.clone()); - let selected_logical = logical - .as_ref() - .and_then(|roots| selected_logical(roots, entries, data)); - return Stage1ToStage3Run { - stage1_pass1_per_computation: pruned, - combinations, - stage1_logical_asap_optimization: Vec::new(), - stage2_physical_asap_optimization: Vec::new(), - stage2_pruned: Vec::new(), - stage3_plan_selection: selection, - stage3_selected: selected, - stage3_selected_logical: selected_logical, - // The selected plan's logical candidate, under the id its - // physical candidates name (`from_logical`). - stage1_selected: logical - .zip(from_logical) - .map(|(roots, id)| LogicalCandidate { - id, - choice: Vec::new(), - roots: Ok(roots), - pass2: None, - }), - }; + let routed_to_legacy = match (search.exhaustive, search.search) { + (_, SearchAlgorithm::Legacy) => None, + (true, _) => Some(RoutedToLegacy::ExhaustiveRequested), + (false, SearchAlgorithm::MemoBruteForce) => { + let pruned = + per_computation.without_unsupported_summaries(models.deployment_capabilities); + let found = super::subexpr::search::brute_force(&pruned, entries, data, models); + return memo_run( + pruned, + found, + entries, + data, + SearchAlgorithm::MemoBruteForce, + ); + } + (false, SearchAlgorithm::MemoMilp) => { + if !matches!( + models.plan_selection.cost_model, + CostModel::UsageOverTime(_) + ) { + Some(RoutedToLegacy::CostModelNotSupported) + } else { + let pruned = per_computation + .clone() + .without_unsupported_summaries(models.deployment_capabilities); + let found = super::subexpr::milp::search( + &pruned, + entries, + data, + models, + search.milp_time_limit_ms, + ); + return memo_run(pruned, found, entries, data, SearchAlgorithm::MemoMilp); + } + } + }; + let mut run = legacy_search(per_computation, entries, data, models, search); + if let Ok(selection) = &mut run.stage3_plan_selection { + selection.search.algorithm = SearchAlgorithm::Legacy; + selection.search.routed_to_legacy = routed_to_legacy; + } + run +} + +/// The run of a memo search (`algorithm`) over `per_computation`, pruned +/// for the deployment, that `found` the selection, its physical candidate +/// and its logical roots, or why none is valid. +fn memo_run( + per_computation: PerComputationCandidates, + found: super::subexpr::milp::MemoSearchResult, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + algorithm: SearchAlgorithm, +) -> Stage1ToStage3Run { + let combinations = per_computation.combinations(); + let (selection, selected, logical) = match found { + Ok((mut selection, selected, logical)) => { + selection.search.algorithm = algorithm; + (Ok(selection), Some(selected), Some(logical)) + } + Err(error) => (Err(error), None, None), + }; + let selected_logical = logical + .as_ref() + .and_then(|roots| selected_logical(roots, entries, data)); + let from_logical = selected.as_ref().map(|s| s.from_logical.clone()); + Stage1ToStage3Run { + stage1_pass1_per_computation: per_computation, + combinations, + stage1_logical_asap_optimization: Vec::new(), + stage2_physical_asap_optimization: Vec::new(), + stage2_pruned: Vec::new(), + stage3_plan_selection: selection, + stage3_selected: selected, + stage3_selected_logical: selected_logical, + // The selected plan's logical candidate, under the id its physical + // candidates name (`from_logical`). + stage1_selected: logical + .zip(from_logical) + .map(|(roots, id)| LogicalCandidate { + id, + choice: Vec::new(), + roots: Ok(roots), + pass2: None, + }), } +} + +/// The legacy search ([`SearchAlgorithm::Legacy`]) of +/// [`run_stage1_to_stage3_from_pass1`]: independent components searched +/// separately when that is proven exact, else the workload as one. +fn legacy_search( + per_computation: PerComputationCandidates, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, + search: Stage1ToStage3Search, +) -> Stage1ToStage3Run { let components = super::components::sharing_components(&per_computation, entries, &models); let searched_as_one = match super::components::searched_as_one(&components, &models, search) { Some(reason) => reason, diff --git a/crates/plan-selection/src/pass/subexpr/coefficients.rs b/crates/plan-selection/src/pass/subexpr/coefficients.rs new file mode 100644 index 000000000..91efa9ebe --- /dev/null +++ b/crates/plan-selection/src/pass/subexpr/coefficients.rs @@ -0,0 +1,1994 @@ +//! Each group's choices and their coefficients for the MILP ([`super::milp`]), +//! from Stage 3's per-node pricing. +//! +//! A choice is one candidate of a group with an option for each of its +//! private summary builds (its slots), as Stage 2 offers them for the +//! group's queries, down-closure applied. Its candidate's sub-DAG is built +//! by Stage 2 once per assignment, one root per query of the group, and +//! every node is measured for one computation ([`crate::price_node`] at +//! rate `(1, 1.0)`). Its private nodes (those not in a child group's own +//! sub-DAG, by structure) are split into parts by who computes them, as +//! Stage 3's `computations` counts consumers: +//! +//! - the direct part: reached from the candidate's node without passing a +//! materialized slot, computed once per consumer reaching the group; +//! - one part per materialized slot: the slot and what it reaches down to +//! the next materialized slot or a child, computed by the slot. +//! +//! Where each field comes from (all of [`crate::price_node`]'s outputs for +//! one computation, cost model 1's [`crate::ResourceUnitCost`]): +//! +//! | Field | Source | +//! | --- | --- | +//! | `cost_per_unit_rate` | `cpu_ops_per_second · cost_per_cpu_op + scanned_bytes_per_second · cost_per_scanned_byte` | +//! | `fixed_cost` | at ingestion: the per-computation price at rate 1, plus kept state; at query time: kept state only. Kept state is `average_memory_bytes · cost_per_memory_byte_second + disk_bytes · cost_per_disk_byte_second`, a pane chain's from `retained_panes` over `pane_retention_ms` with the group's queries' range spans | +//! | `work_ms` | `work_ms` of the part's query-time nodes (`query_latencies` adds each reached node once) | +//! | [`LazyTerms::held`] | `held`: bytes held while an evaluation runs, charged by Stage 3 as `bytes · per_second · E / 1000 · cost_per_memory_byte_second`, `E` the readers' rate-weighted mean latency (per reader) or longest (built once) | +//! | [`LazyTerms::raw_scans`] | `raw_scan`: Stage 3 keeps each source's longest query-time scan on disk | +//! +//! Limits: a child's output statistics are its group's own node's, as the +//! candidate's sub-DAG reads it, whatever the child's chosen +//! implementation; a query's request overhead (a measured table's +//! `ns_per_evaluation`) and an ingestion state's compaction and partial +//! copies with more than one ingest worker are not coefficients; a kept +//! chain's slot is charged per unit of its group's union rate, while +//! Stage 3 computes it once per request (range steps not counted). + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::rc::Rc; + +use asap_physical_asap_optimization::{optimize_physical_asap, PhysicalCandidate}; +use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPNodeId}; +use asap_types::ir::properties::ExecutionTiming; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot}; +use asap_types::physical::{Materialization, Medium}; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +use super::milp::{ + Choice, ChoiceCoefficients, PartCoefficients, SharedNodeCoefficients, SlotCoefficients, Via, +}; +use super::pricing::{child_keys, keys_of, scan_lookback_ms, PriceCache}; +use super::{CouplingKind, CouplingSite, GroupId, ImplementationCandidate, Memo}; +use crate::{ + kept_pane_chains, maintained_window_ms, node_map, pane_retention_ms, price_node, query_reach, + retained_panes, source_name, windows_read, CostModel, PlanningModels, PricedNode, + ProducerOutput, SelectionError, +}; + +/// The terms of a choice the MILP adds lazily, once its plan's latencies +/// and every source's scans are known. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LazyTerms { + /// Memory each part holds while an evaluation runs. + pub held: Vec, + /// Each query-time raw scan of a part: source, bytes, part. + pub raw_scans: Vec<(String, u64, Via)>, + /// The direct part computed at ingestion time, as it is under a + /// parent's ingestion-time slot (scans read one second of rows); + /// `None` when it cannot run at ingestion time (a grouping with no + /// window below it). + pub direct_at_ingestion: Option, + /// The threshold of each coupling site the choice's candidate reads it + /// at (index among the site's thresholds); a raw-horizon site's is + /// Stage 3's own. + pub thresholds: Vec<(CouplingSite, usize)>, + /// When the group's own node is built once at query time (in memory): + /// the state it holds while its longest reader runs, charged in place + /// of its [`HeldTerm::own_node`] working memory. + pub built_once_state_bytes: u64, +} + +/// Bytes a part holds while an evaluation runs: Stage 3 charges +/// `bytes · rate · E / 1000` byte-seconds per second, `rate` the part's +/// consumers' rate and `E` the queries reaching it: their rate-weighted +/// mean latency when each computes its own copy (`per_reader`), else the +/// longest. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct HeldTerm { + pub via: Via, + pub bytes: u64, + pub per_reader: bool, + /// Whether it is the group's own node (replaced when it is built once). + pub own_node: bool, + /// Its node's shallow key ([`shallow_keys`]). + pub node_key: u32, +} + +/// Every choice of `group` with its lazy terms, in candidate order and, +/// within a candidate, in Stage 2's assignment order. +pub(crate) fn choices( + memo: &Memo, + group: GroupId, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, + cache: &mut PriceCache, +) -> Result<(Vec, Vec), SelectionError> { + let parts = choice_parts(memo, group, entries, data, models, cache)?; + Ok(parts + .into_iter() + .map(|part| (part.choice, part.lazy)) + .unzip()) +} + +/// The threshold of each coupling site a candidate is read at (index among +/// the site's thresholds). +type SiteThresholds = Vec<(CouplingSite, usize)>; + +/// A choice with the shallow keys ([`shallow_keys`]) of its nodes, by +/// which a composed plan's nodes are found (tests). +pub(super) struct ChoiceParts { + pub choice: Choice, + pub lazy: LazyTerms, + /// The shallow key of the candidate's node. + pub root_key: u32, + /// The shallow keys of the candidate node's private producers. + pub root_producer_keys: Vec, + /// Per shared node of the coefficients, the shallow keys of the nodes + /// it computes when built once, itself first. + pub shared_node_keys: Vec>, + /// Per slot of the coefficients, the shallow key of its build. + pub slot_keys: Vec, + /// The shallow keys of the private builds, in assignment order. + pub build_keys: Vec, + /// Per part (direct, then each slot), its nodes' keys. + pub part_keys: Vec>, +} + +pub(super) fn choice_parts( + memo: &Memo, + group: GroupId, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, + cache: &mut PriceCache, +) -> Result, SelectionError> { + let node = &memo.groups[group.0 as usize].node; + let queries = &memo.groups[group.0 as usize].queries; + let windows_of_raw = Rc::clone( + cache + .raw_windows + .get_or_insert_with(|| Rc::new(raw_windows(memo))), + ); + let is_scan = matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })); + // Shared (one node for every query), and for a group several queries + // read, apart (a copy per query); a scan apart is its reader's. + let mut modes: Vec> = vec![None]; + if queries.len() > 1 && !is_scan { + modes.extend(queries.iter().map(|&q| Some(q))); + } + // A shared scan reads the longest window of its readers: one choice + // per horizon a reader may read. + let own_horizons: Vec> = match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => windows_of_raw + .get(&super::scans::raw_horizon_key(source)) + .map(|windows| windows.iter().map(|&w| Some(w)).collect()) + .unwrap_or_else(|| vec![None]), + _ => vec![None], + }; + let mut out = Vec::new(); + let versions: Vec<(usize, ImplementationCandidate, SiteThresholds)> = memo.candidates + [group.0 as usize] + .iter() + .enumerate() + .flat_map(|(index, candidate)| { + at_site_thresholds(memo, candidate) + .into_iter() + .map(move |(version, thresholds)| (index, version, thresholds)) + }) + .collect(); + for copy_of in modes { + // A copy for one query is built for it twice, so that Stage 2 + // offers its builds built once for several readers (`MatAtQuery`): + // another group's or copy's identical build may share it. + let built_for: Vec = match (copy_of, queries.as_slice()) { + (Some(q), _) | (None, &[q]) => vec![q, q], + (None, _) => queries.clone(), + }; + let part_entries: Vec = + built_for.iter().map(|&q| entries[q].clone()).collect(); + for (index, candidate, thresholds) in &versions { + let (index, candidate) = (*index, candidate); + let roots = + vec![QueryRoot::Operator(Rc::clone(&candidate.node)); built_for.len().max(1)]; + let Ok(physicals) = optimize_physical_asap( + "S", + &roots, + &part_entries, + data, + models.deployment_capabilities, + ) else { + continue; + }; + let child_groups = children_of(memo, group, candidate); + let child_key_sets: Vec> = child_groups + .iter() + .map(|&child| child_keys(memo, child, entries, data, &models, cache)) + .collect(); + // Each scan child: apart (its nodes this choice's) or shared at + // a horizon no shorter than this choice's window on it. + let scan_children: Vec<(usize, u64, Vec>)> = child_groups + .iter() + .enumerate() + .filter_map( + |(i, child)| match memo.groups[child.0 as usize].node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => { + let key = super::scans::raw_horizon_key(source); + let mut variants = vec![None]; + variants.extend( + windows_of_raw + .get(&key) + .into_iter() + .flatten() + .map(|&w| Some(w)), + ); + Some((i, key, variants)) + } + _ => None, + }, + ) + .collect(); + let mut combinations: Vec>> = vec![Vec::new()]; + for (_, _, variants) in &scan_children { + combinations = combinations + .into_iter() + .flat_map(|chosen| { + variants.iter().map(move |v| { + let mut chosen = chosen.clone(); + chosen.push(*v); + chosen + }) + }) + .collect(); + } + for own_horizon in &own_horizons { + for combination in &combinations { + // Children whose nodes are not this choice's, and the + // horizon each shared scan child reads. + let mut horizons: HashMap = HashMap::new(); + let mut absorbed: Vec = Vec::new(); + for ((i, key, _), variant) in scan_children.iter().zip(combination) { + match variant { + Some(h) => { + horizons.insert(*key, *h); + } + None => absorbed.push(*i), + } + } + let mut children: HashSet = HashSet::new(); + let mut child_roots: Vec> = Vec::new(); + let mut kept_children: Vec = Vec::new(); + for (i, keys) in child_key_sets.iter().enumerate() { + if absorbed.contains(&i) { + continue; + } + children.extend(keys.nodes.iter().copied()); + child_roots.push(keys.root); + kept_children.push(child_groups[i]); + } + let mut seen: Vec> = Vec::new(); + for physical in &physicals { + // A shared scan's horizon is the longest window of + // its readers: no shorter than this choice's own + // window on it (through time shifts). + if !horizons.is_empty() { + let dag = &physical.dag; + let nodes = node_map(dag); + let kept = kept_pane_chains(dag); + let too_short = dag.nodes.iter().any(|n| match &n.payload { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => horizons + .get(&super::scans::raw_horizon_key(source)) + .is_some_and(|&h| { + windows_read(dag, &nodes, n.id, &kept) + .into_iter() + .any(|w| w > h) + }), + _ => false, + }); + if too_short { + continue; + } + } + let keys = keys_of(&physical.dag, cache.keys_mut()); + // The children its plan reads (a rollup reads its + // finer group's state, not its own window's group). + let (read_children, read_roots): (Vec, Vec>) = + kept_children + .iter() + .zip(&child_roots) + .filter(|(_, root)| root.is_some_and(|r| keys.contains(&r))) + .map(|(c, r)| (*c, *r)) + .unzip(); + let shallow = shallow_keys(&physical.dag, cache.keys_mut()); + let widened = widened_keys_of(&physical.dag, cache.keys_mut()); + let private: Vec = + keys.iter().map(|k| !children.contains(k)).collect(); + let builds: Vec = (0..physical.dag.nodes.len()) + .filter(|&i| private[i] && is_build(&physical.dag.nodes[i].payload)) + .collect(); + // Only the private builds are the choice's; any + // other materialized node (the group's own node + // built once, a child's build) is another variable + // of the model. + let foreign = physical.dag.nodes.iter().enumerate().any(|(i, n)| { + n.materialization != Materialization::NotMaterialized + && !builds.contains(&i) + }); + if foreign { + continue; + } + let assignment: Vec = builds + .iter() + .map(|&i| physical.dag.nodes[i].materialization) + .collect(); + if seen.contains(&assignment) { + continue; + } + seen.push(assignment.clone()); + let Some(parts) = coefficients_of( + physical, + &keys, + &shallow, + &widened, + &private, + &read_roots, + &horizons, + own_horizon.or_else(|| scan_lookback_ms(memo, group)), + &part_entries, + data, + &models, + )? + else { + continue; + }; + out.push(ChoiceParts { + choice: Choice { + group, + candidate: index, + implements: candidate.implements.clone(), + // The children it reads first (`child_via` + // is theirs), then the ones its candidate + // lists without reading: needed by the + // plan, but no consumer passes into them. + children: read_children + .iter() + .chain( + kept_children.iter().filter(|c| !read_children.contains(c)), + ) + .copied() + .collect(), + assignment, + coefficients: parts.coefficients, + copy_of, + scan_children: scan_children + .iter() + .zip(combination) + .map(|((i, _, _), v)| (child_groups[*i], *v)) + .collect(), + scan_horizon_ms: *own_horizon, + scan_source: match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => { + Some(super::scans::raw_horizon_key(source)) + } + _ => None, + }, + scan_project_cost_per_unit_rate: parts.scan_project_cost, + }, + lazy: LazyTerms { + thresholds: thresholds.clone(), + ..parts.lazy + }, + root_key: parts.root_key, + root_producer_keys: parts.root_producer_keys, + shared_node_keys: parts.shared_node_keys, + slot_keys: parts.slot_keys, + build_keys: builds.iter().map(|&i| shallow[i]).collect(), + part_keys: parts.part_keys, + }); + } + } + } + } + } + Ok(out) +} + +/// `candidate` rewritten at each combination of thresholds of its coupling +/// sites that it may read, as the brute-force search rewrites a plan's +/// candidates, with the index of each threshold. A raw-horizon site has +/// no threshold here: scan sharing is part of each choice +/// ([`Choice::scan_children`]). A candidate without sites is itself. +fn at_site_thresholds( + memo: &Memo, + candidate: &ImplementationCandidate, +) -> Vec<(ImplementationCandidate, Vec<(CouplingSite, usize)>)> { + use super::{capability, scans, windows}; + let mut versions = vec![(candidate.clone(), Vec::new())]; + for site in &candidate.coupling_sites { + let mut next = Vec::new(); + for (version, chosen) in versions { + let at = |index: usize, rewritten: Option| { + rewritten.map(|rewritten| { + let mut chosen = chosen.clone(); + chosen.push((*site, index)); + (rewritten, chosen) + }) + }; + match site.kind { + CouplingKind::PaneChain => { + for (index, threshold) in + windows::chain_thresholds(memo, site).iter().enumerate() + { + let readers_ok = windows::chain_readers(&version.node) + .iter() + .filter(|r| r.key == site.key) + .all(|r| windows::may_read(threshold, r)); + let rewritten = readers_ok + .then(|| windows::read_at_threshold(&version, site, threshold).ok()) + .flatten() + .map(|node| ImplementationCandidate { + node, + ..version.clone() + }); + next.extend(at(index, rewritten)); + } + } + CouplingKind::ScanLabels => { + for (index, threshold) in scans::label_thresholds(memo, site).iter().enumerate() + { + let rewritten = + scans::widened_at_threshold(memo, &version, site, threshold) + .ok() + .flatten(); + next.extend(at(index, rewritten)); + } + } + // A sketch rollup's finer build, sized for its readers, as + // the brute force sizes it. + CouplingKind::SharedBuildSize if windows::is_rollup_state(memo, site) => { + for (index, threshold) in windows::state_size_thresholds(memo, site) + .iter() + .enumerate() + { + let rewritten = windows::may_read_state(threshold, &version, site) + .then(|| windows::resize_state_at(&version, site, threshold).ok()) + .flatten() + .map(|node| ImplementationCandidate { + node, + ..version.clone() + }); + next.extend(at(index, rewritten)); + } + } + CouplingKind::SharedBuildSize => { + for (index, threshold) in + capability::size_thresholds(memo, site).iter().enumerate() + { + let rewritten = + capability::resized_at_threshold(memo, &version, site, threshold) + .ok() + .flatten() + .map(|node| ImplementationCandidate { + node, + ..version.clone() + }); + next.extend(at(index, rewritten)); + } + } + // A raw horizon is the look-back of a shared scan: each + // choice prices its scan children shared at a horizon or + // apart ([`Choice::scan_children`]). + CouplingKind::RawHorizon => next.push((version, chosen)), + } + } + versions = next; + } + versions +} + +/// Every window a raw scan of each source may be read for, by the source's +/// raw-horizon key: each range over it (through time shifts), and each +/// pane chain over it, kept across evaluations (its pane width) or not +/// (its panes), in every candidate and every chain threshold of the memo. +fn raw_windows(memo: &Memo) -> HashMap> { + use asap_types::ir::OperatorNode; + let mut windows: HashMap> = HashMap::new(); + let scan_below = |node: &Rc| -> Option { + let mut node = Rc::clone(node); + loop { + match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => { + return Some(super::scans::raw_horizon_key(source)) + } + Some(NonASAPOp::TimeShift { child, .. }) => node = Rc::clone(child), + _ => return None, + } + } + }; + for candidate in memo.candidates.iter().flatten() { + for (version, _) in at_site_thresholds(memo, candidate) { + for node in OperatorNode::reachable(&version.node) { + match (node.non_asap(), node.asap()) { + (Some(NonASAPOp::TimeRange { range, child, .. }), _) => { + if let Some(key) = scan_below(child) { + let ms = u64::try_from(range.as_millis()).unwrap_or(u64::MAX); + windows.entry(key).or_default().insert(ms); + } + } + ( + _, + Some(ASAPOp::PaneChain { + child, + pane_width_ms, + pane_count, + .. + }), + ) => { + if let Some(key) = scan_below(child) { + let entry = windows.entry(key).or_default(); + entry.insert(*pane_width_ms); + entry.insert(pane_width_ms.saturating_mul(u64::from(*pane_count))); + } + } + _ => {} + } + } + } + } + windows +} + +/// Each node's key up to scan widening, by position: its payload over its +/// producers' keys, each scan by its source alone (the identical-expression +/// rule may widen a source's scans to one label set), schemas left out. +/// Equal keys of two groups' nodes mean the nodes are one node once shared. +fn widened_keys_of(dag: &PhysicalASAPDAG, interned: &mut HashMap) -> Vec { + let mut key_of: HashMap = HashMap::new(); + dag.nodes + .iter() + .map(|node| { + let text = match &node.payload { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => { + format!("widened scan {source:?}") + } + payload => { + let payload = payload.map_children(|id| key_of.get(id).copied()); + format!( + "widened {}", + serde_json::to_string(&payload).expect("a physical node serializes") + ) + } + }; + let next = interned.len() as u32; + let key = *interned.entry(text).or_insert(next); + key_of.insert(node.id, key); + key + }) + .collect() +} + +/// The child groups of `candidate`: its own, and every other group whose +/// node equals a node its node reaches and that no other child already +/// reaches, which some Pass 2 candidates read without listing (a pane +/// chain over its window's scan). Their nodes are not the candidate's +/// (the composed plan makes equal nodes one), and a plan choosing it +/// needs them. +fn children_of(memo: &Memo, group: GroupId, candidate: &ImplementationCandidate) -> Vec { + use asap_types::ir::OperatorNode; + let mut children = candidate.children.clone(); + let reachable = OperatorNode::reachable(&candidate.node); + let reaches = |nodes: &[Rc], g: GroupId| { + let node = &memo.groups[g.0 as usize].node; + nodes.iter().any(|n| { + Rc::ptr_eq(n, node) || (n.operator == node.operator && n.schema == node.schema) + }) + }; + let below = |g: GroupId, children: &[GroupId]| { + children + .iter() + .any(|&c| reaches(&OperatorNode::reachable(&memo.groups[c.0 as usize].node), g)) + }; + // Parents before children, so the highest group read is taken first. + for h in (0..memo.groups.len()).rev() { + let h = GroupId(h as u32); + if h == group || candidate.implements.contains(&h) || children.contains(&h) { + continue; + } + if !Rc::ptr_eq(&memo.groups[h.0 as usize].node, &candidate.node) + && memo.groups[h.0 as usize].node.operator != candidate.node.operator + && reaches(&reachable, h) + && !below(h, &children) + { + children.push(h); + } + } + children +} + +fn is_build(payload: &Operator) -> bool { + matches!( + payload, + Operator::ASAP(ASAPOp::SummaryAgg { .. } | ASAPOp::PaneChain { .. }) + ) +} + +struct Coefficients { + coefficients: ChoiceCoefficients, + /// For a scan: what a pass over its output costs per computation. + scan_project_cost: f64, + lazy: LazyTerms, + root_key: u32, + root_producer_keys: Vec, + shared_node_keys: Vec>, + slot_keys: Vec, + part_keys: Vec>, +} + +/// Every node of `dag` measured for one computation, children first. +fn per_computation<'a>( + dag: &'a PhysicalASAPDAG, + entries: &[QueryWorkloadEntry], + scan_lookback: Option, + horizons: &HashMap, + data: &DataWorkload, + models: &'a PlanningModels<'a>, +) -> Result>, SelectionError> { + let nodes = node_map(dag); + let kept = kept_pane_chains(dag); + let reach = query_reach(dag, &nodes); + let ranges: Vec<_> = entries.iter().map(|e| e.range_evaluation).collect(); + let retention = pane_retention_ms(dag, &reach, &ranges); + let mut producers_of: HashMap> = HashMap::new(); + for e in &dag.edges { + producers_of.entry(e.consumer).or_default().push(e.producer); + } + let mut priced: HashMap> = HashMap::new(); + for node in &dag.nodes { + let views: Vec> = producers_of + .get(&node.id) + .map_or(&[][..], Vec::as_slice) + .iter() + .map(|p| { + let produced = &priced[p]; + ProducerOutput { + payload: &nodes[p].payload, + output_schema: &nodes[p].output_schema, + output: produced.output, + sources: produced.sources.clone(), + family: produced.family, + unchanged_columns: produced.unchanged_columns.clone(), + } + }) + .collect(); + let lookback_ms = match (&node.payload, node.output_state.timing) { + (Operator::NonASAP(NonASAPOp::Scan { source, .. }), ExecutionTiming::QueryTime) => { + match horizons.get(&super::scans::raw_horizon_key(source)) { + Some(&horizon) => Some(horizon), + None => windows_read(dag, &nodes, node.id, &kept) + .into_iter() + .max() + .or(scan_lookback), + } + } + _ => None, + }; + let node_priced = price_node( + node, + &views, + (1, 1.0), + lookback_ms, + retained_panes(node, &retention), + (node.output_state.timing == ExecutionTiming::IngestionTime) + .then(|| maintained_window_ms(node.id, &nodes, &producers_of)) + .flatten(), + data, + models.plan_selection, + 1.0, + ) + .map_err(|error| SelectionError::Cost { + candidate: "memo choice".into(), + node: node.id, + error, + })?; + priced.insert(node.id, node_priced); + } + Ok(priced) +} + +/// Each node's shallow key, by position: its payload with its producers +/// left out, and its output schema. Unlike [`keys_of`] it does not depend +/// on the implementations below a node. +pub(super) fn shallow_keys(dag: &PhysicalASAPDAG, interned: &mut HashMap) -> Vec { + dag.nodes + .iter() + .map(|node| { + let payload = node.payload.map_children(|_| ()); + let text = serde_json::to_string(&("shallow", &payload, &node.output_schema)) + .expect("a physical node serializes"); + let next = interned.len() as u32; + *interned.entry(text).or_insert(next) + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn coefficients_of( + physical: &PhysicalCandidate, + keys: &[u32], + shallow: &[u32], + widened: &[u32], + private: &[bool], + child_roots: &[Option], + horizons: &HashMap, + scan_lookback: Option, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: &PlanningModels<'_>, +) -> Result, SelectionError> { + let CostModel::UsageOverTime(prices) = &models.plan_selection.cost_model else { + return Ok(None); + }; + let dag = &physical.dag; + let Some(&root) = dag.roots.first() else { + return Ok(None); + }; + let position: HashMap = dag + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id, i)) + .collect(); + if !private[position[&root]] { + return Ok(None); + } + let priced = per_computation(dag, entries, scan_lookback, horizons, data, models)?; + let mut producers_of: HashMap> = HashMap::new(); + for e in &dag.edges { + producers_of.entry(e.consumer).or_default().push(e.producer); + } + let is_private = |id: PhysicalASAPNodeId| private[position[&id]]; + let materialized = |id: PhysicalASAPNodeId| { + dag.nodes[position[&id]].materialization != Materialization::NotMaterialized + }; + // A part's nodes: from `start`, private nodes, not past a materialized + // one (other than `start`); and the non-private nodes it reads. + let walk = |start: PhysicalASAPNodeId| { + let mut part = Vec::new(); + let mut reads = Vec::new(); + let mut seen = HashSet::new(); + let mut stack = vec![start]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + if !is_private(id) { + reads.push(id); + continue; + } + if id != start && materialized(id) { + continue; + } + part.push(id); + stack.extend(producers_of.get(&id).into_iter().flatten().copied()); + } + (part, reads) + }; + let per_rate = |id: PhysicalASAPNodeId| { + let p = &priced[&id]; + p.cpu_ops_per_second * prices.cost_per_cpu_op + + p.scanned_bytes_per_second * prices.cost_per_scanned_byte + }; + let kept_state = |id: PhysicalASAPNodeId| { + let p = &priced[&id]; + p.average_memory_bytes * prices.cost_per_memory_byte_second + + p.disk_bytes as f64 * prices.cost_per_disk_byte_second + }; + let query_time = |id: PhysicalASAPNodeId| { + dag.nodes[position[&id]].output_state.timing == ExecutionTiming::QueryTime + }; + let work = |part: &[PhysicalASAPNodeId]| -> f64 { + part.iter() + .filter(|&&id| query_time(id)) + .map(|id| priced[id].work_ms) + .sum() + }; + let mut held = Vec::new(); + let mut raw_scans = Vec::new(); + let mut lazy_of = |part: &[PhysicalASAPNodeId], via: Via| { + for &id in part { + let p = &priced[&id]; + if let Some((bytes, _, per_reader)) = p.held { + held.push(HeldTerm { + via, + bytes, + per_reader, + own_node: id == root, + node_key: shallow[position[&id]], + }); + } + if let Some((source, bytes)) = p.raw_scan { + raw_scans.push((source_name(source).to_string(), bytes, via)); + } + } + }; + let (direct, direct_reads) = walk(root); + lazy_of(&direct, Via::Direct); + let direct_coefficients = PartCoefficients { + cost_per_unit_rate: direct.iter().map(|&id| per_rate(id) + kept_state(id)).sum(), + work_ms: work(&direct), + }; + let absorbed_scans: Vec<(u64, f64, f64)> = direct + .iter() + .filter(|&&id| query_time(id)) + .filter_map(|&id| match &dag.nodes[position[&id]].payload { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => Some(( + super::scans::raw_horizon_key(source), + per_rate(id) + kept_state(id), + prices.cost_per_cpu_op * priced[&id].output.rows as f64, + )), + _ => None, + }) + .collect(); + // Shareable nodes of the direct part (another copy's or group's + // identical node may be one with it), each with what it computes when + // built once: itself and the direct nodes only it reaches. + let mut shared_nodes = Vec::new(); + let mut shared_node_keys = Vec::new(); + let direct_set: HashSet = direct.iter().copied().collect(); + // Each shareable node with the direct nodes below it. + let shareable: Vec<(PhysicalASAPNodeId, Vec)> = direct + .iter() + .copied() + .filter(|&id| { + let node = &dag.nodes[position[&id]]; + let shareable = matches!(node.payload, Operator::ASAP(_)) + || !node.output_schema.unique_keys.is_empty(); + // A scan apart is its reader's own; a shared one is its scan + // group's choice (`Choice::scan_children`). + let scan = matches!(node.payload, Operator::NonASAP(NonASAPOp::Scan { .. })); + id != root && shareable && !scan && !is_build(&node.payload) && query_time(id) + }) + .map(|id| { + // Its scans are charged as scans (`absorbed_scans`). + let below = walk(id) + .0 + .into_iter() + .filter(|n| direct_set.contains(n)) + .filter(|n| { + !matches!( + dag.nodes[position[n]].payload, + Operator::NonASAP(NonASAPOp::Scan { .. }) + ) + }) + .collect(); + (id, below) + }) + .collect(); + // Each direct node belongs to its nearest shareable node (itself or + // above it), so that no node is in two of them: the model charges + // their sum once. + for (id, below) in &shareable { + let id = *id; + let below: Vec = below + .iter() + .copied() + .filter(|n| { + !shareable.iter().any(|(other, other_below)| { + *other != id && below.contains(other) && other_below.contains(n) + }) + }) + .collect(); + let mut once = dag.clone(); + once.nodes[position[&id]].materialization = Materialization::MatAtQuery(Medium::Memory); + let state_bytes = per_computation(&once, entries, scan_lookback, horizons, data, models)? + [&id] + .held + .map_or(0, |(bytes, _, _)| bytes); + shared_nodes.push(SharedNodeCoefficients { + cost_per_unit_rate: below.iter().map(|&n| per_rate(n)).sum(), + work_ms: work(&below), + state_bytes, + members: below + .iter() + .map(|n| u64::from(widened[position[n]])) + .collect(), + }); + shared_node_keys.push( + below + .iter() + .map(|n| shallow[position[n]]) + .collect::>(), + ); + } + let mut part_keys = vec![direct.iter().map(|id| keys[position[id]]).collect()]; + let mut slots = Vec::new(); + let mut slot_keys = Vec::new(); + let mut slot_reads = Vec::new(); + for node in &dag.nodes { + if !is_private(node.id) || node.materialization == Materialization::NotMaterialized { + continue; + } + let (part, reads) = walk(node.id); + let via = Via::Slot(slots.len()); + lazy_of(&part, via); + let at_ingestion = matches!(node.materialization, Materialization::MatAtIngestion(_)); + // A query-time slot's raw scans are charged as scans. + let scans: Vec<(u64, f64, f64)> = match at_ingestion { + true => Vec::new(), + false => part + .iter() + .filter(|&&id| query_time(id)) + .filter_map(|&id| match &dag.nodes[position[&id]].payload { + Operator::NonASAP(NonASAPOp::Scan { source, .. }) => Some(( + super::scans::raw_horizon_key(source), + per_rate(id), + prices.cost_per_cpu_op * priced[&id].output.rows as f64, + )), + _ => None, + }) + .collect(), + }; + let rate_cost: f64 = part.iter().map(|&id| per_rate(id)).sum::() + - scans.iter().map(|&(_, cost, _)| cost).sum::(); + let state: f64 = part.iter().map(|&id| kept_state(id)).sum(); + slots.push(SlotCoefficients { + materialization: node.materialization, + fixed_cost: match at_ingestion { + true => rate_cost + state, + false => state, + }, + cost_per_unit_rate: match at_ingestion { + true => 0.0, + false => rate_cost, + }, + work_ms: work(&part), + // Builds that are one node when shared (a site's build at one + // threshold, identical expressions, scans widened to one label + // set): equal over equal inputs, scans' projections aside. + shared_site: Some((u64::from(widened[position[&node.id]]), 0)), + scans, + }); + slot_keys.push(shallow[position[&node.id]]); + part_keys.push(part.iter().map(|id| keys[position[id]]).collect()); + slot_reads.push(reads); + } + let child_via: Vec = child_roots + .iter() + .map(|child| { + let reads_child = |reads: &[PhysicalASAPNodeId]| { + reads.iter().any(|id| Some(keys[position[id]]) == *child) + }; + match reads_child(&direct_reads) { + true => Via::Direct, + false => slot_reads + .iter() + .position(|reads| reads_child(reads)) + .map_or(Via::Direct, Via::Slot), + } + }) + .collect(); + // The direct part at ingestion time: the whole sub-DAG placed at + // ingestion time, its direct nodes counted. + let mut at_ingestion = dag.clone(); + for node in &mut at_ingestion.nodes { + node.output_state.timing = ExecutionTiming::IngestionTime; + } + let direct_at_ingestion = per_computation( + &at_ingestion, + entries, + scan_lookback, + horizons, + data, + models, + ) + .ok() + .map(|ingestion_priced| PartCoefficients { + cost_per_unit_rate: direct + .iter() + .map(|id| { + let p = &ingestion_priced[id]; + p.cpu_ops_per_second * prices.cost_per_cpu_op + + p.scanned_bytes_per_second * prices.cost_per_scanned_byte + + p.average_memory_bytes * prices.cost_per_memory_byte_second + + p.disk_bytes as f64 * prices.cost_per_disk_byte_second + }) + .sum(), + work_ms: 0.0, + }); + // The group's own node built once at query time, in memory: the state + // it holds instead of its working memory. + let mut built_once = dag.nodes[position[&root]].clone(); + built_once.materialization = Materialization::MatAtQuery(Medium::Memory); + let built_once_state_bytes = { + let mut once_dag = dag.clone(); + once_dag.nodes[position[&root]] = built_once; + let once_priced = + per_computation(&once_dag, entries, scan_lookback, horizons, data, models)?; + once_priced[&root].held.map_or(0, |(bytes, _, _)| bytes) + }; + Ok(Some(Coefficients { + coefficients: ChoiceCoefficients { + direct: direct_coefficients, + slots, + child_via, + shared_nodes, + cross_shared: Vec::new(), + absorbed_scans, + direct_nodes: direct + .iter() + .filter(|&&id| query_time(id)) + .filter(|&&id| { + !matches!( + dag.nodes[position[&id]].payload, + Operator::NonASAP(NonASAPOp::Scan { .. }) + ) + }) + .map(|&id| { + ( + u64::from(widened[position[&id]]), + per_rate(id) + kept_state(id), + ) + }) + .collect(), + }, + lazy: LazyTerms { + held, + raw_scans, + direct_at_ingestion, + built_once_state_bytes, + thresholds: Vec::new(), + }, + scan_project_cost: match dag.nodes[position[&root]].payload { + Operator::NonASAP(NonASAPOp::Scan { .. }) => { + prices.cost_per_cpu_op * priced[&root].output.rows as f64 + } + _ => 0.0, + }, + root_key: shallow[position[&root]], + shared_node_keys, + root_producer_keys: producers_of + .get(&root) + .into_iter() + .flatten() + .filter(|p| is_private(**p)) + .map(|p| shallow[position[p]]) + .collect(), + slot_keys, + part_keys, + })) +} + +/// The plan-level charges of `lazy` for one choice in a plan (cost model +/// 1): each held term at its part's rate and its readers' latencies. +/// `part_rate(via)` is the rate of the part's consumers, `readers(via)` the +/// queries reaching it with their rates, `latency_ms(q)` query `q`'s +/// latency, `built_once` whether the group's own node is built once. +pub(crate) fn held_cost( + lazy: &LazyTerms, + built_once: bool, + part_rate: impl Fn(Via) -> f64, + readers: impl Fn(Via) -> Vec<(usize, f64)>, + latency_ms: impl Fn(usize) -> f64, + cost_per_memory_byte_second: f64, +) -> f64 { + let mut cost = 0.0; + let built_once_term = HeldTerm { + via: Via::Direct, + bytes: lazy.built_once_state_bytes, + per_reader: false, + own_node: true, + node_key: u32::MAX, + }; + let terms = lazy + .held + .iter() + .filter(|term| !(built_once && term.own_node)) + .chain(built_once.then_some(&built_once_term)); + for term in terms { + let (bytes, per_reader) = (term.bytes, term.per_reader); + let readers = readers(term.via); + let longest = readers + .iter() + .map(|&(q, _)| latency_ms(q)) + .fold(0.0, f64::max); + let rate: f64 = readers.iter().map(|&(_, r)| r).sum(); + let evaluation_ms = match per_reader && rate > 0.0 { + true => readers.iter().map(|&(q, r)| r * latency_ms(q)).sum::() / rate, + false => longest, + }; + cost += cost_per_memory_byte_second * bytes as f64 * part_rate(term.via) * evaluation_ms + / 1_000.0; + } + cost +} + +/// Each source's longest raw scan among `scans` (Stage 3 keeps it on disk +/// once). +pub(crate) fn raw_data_bytes<'s>( + scans: impl IntoIterator, +) -> BTreeMap<&'s str, u64> { + let mut longest: BTreeMap<&str, u64> = BTreeMap::new(); + for (source, bytes) in scans { + let entry = longest.entry(source.as_str()).or_default(); + *entry = (*entry).max(*bytes); + } + longest +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pass::subexpr::search::{ + at_thresholds, composed_roots, group_choices, identical_expression_variants, memo, + resized_builds, + }; + use crate::{computations, price_at_scale}; + use asap_logical_asap_optimization::per_computation_candidates::generate_per_computation_candidates; + use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_types::ir::properties::AccuracyTarget; + use asap_types::ir::schema::schema_support::with_promql_series_identity; + use asap_types::workload::{ + DataArrival, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, + SourceFacts, + }; + + fn data() -> DataWorkload { + DataWorkload { + arrival: DataArrival::Ingesting { + rows_per_second: 10_000.0, + }, + sources: [( + "m".to_string(), + SourceFacts { + series_count: Some(10_000), + ..SourceFacts::default() + }, + )] + .into_iter() + .collect(), + } + } + + fn entries(intervals_ms: &[u32], targets: &[AccuracyTarget]) -> Vec { + intervals_ms + .iter() + .zip(targets) + .map(|(&ms, target)| { + let mut entry = entry_with( + target.clone(), + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( + ms, + ))), + ); + entry.predictability = Predictability::Predictable { known_at: None }; + entry + }) + .collect() + } + + /// For every plan of `queries`' memo (every choice and threshold, every + /// Stage 2 materialization), Σ of its choices' coefficients times the + /// true consumer rates, plus the lazy terms, is Stage 3's price. + fn check(queries: &[&str], intervals_ms: &[u32]) -> Vec> { + let exact = vec![AccuracyTarget::exact(); queries.len()]; + check_with(queries, intervals_ms, &exact) + } + + /// [`check`] with each query's accuracy target; the Stage 2 + /// assignments of the plans checked. + fn check_with( + queries: &[&str], + intervals_ms: &[u32], + targets: &[AccuracyTarget], + ) -> Vec> { + let roots: Vec<(usize, QueryRoot)> = queries + .iter() + .zip(targets) + .map(|(q, target)| { + let root = lower_promql(q, target.clone()); + QueryRoot::Operator(with_promql_series_identity(&root).unwrap()) + }) + .enumerate() + .collect(); + let per_computation = generate_per_computation_candidates(roots).unwrap(); + let entries = entries(intervals_ms, targets); + let data = data(); + let models = PlanningModels::builtin(); + let settings = models.plan_selection; + let CostModel::UsageOverTime(prices) = &settings.cost_model else { + unreachable!() + }; + let memo = memo(&per_computation, &entries); + let mut cache = PriceCache::new(1.0); + let parts: Vec> = (0..memo.groups.len()) + .map(|g| { + choice_parts( + &memo, + GroupId(g as u32), + &entries, + &data, + models, + &mut cache, + ) + .unwrap() + }) + .collect(); + let mut checked = Vec::new(); + let mut unplaced: Vec> = Vec::new(); + for choice in group_choices(&memo).unwrap() { + // The plans the brute force prices: requirements met, scans + // widened only through the identical-expression variants. + let chosen: Vec<(GroupId, &ImplementationCandidate)> = choice + .iter() + .enumerate() + .filter_map(|(g, c)| Some((GroupId(g as u32), &memo.candidates[g][(*c)?]))) + .collect(); + if !crate::pass::subexpr::windows::requirements_met(&chosen) + || chosen + .iter() + .any(|(_, c)| c.origin == crate::pass::subexpr::CandidateOrigin::WidenedScans) + { + continue; + } + for plan in at_thresholds(&memo, &choice) { + let Some(roots) = composed_roots(&memo, &per_computation, &plan) else { + continue; + }; + let roots = crate::pass::subexpr::capability::share_equal_builds( + &roots, + &resized_builds(&memo, &choice, &plan), + ); + // Widened variants (every metric's scans widened to one + // label set) come after the others; they have no choices + // yet (TODO: choices of widened candidates), and are only + // priced exactly. + let unwidened = 1 + asap_logical_asap_optimization::asap_aware_cse::identical_expression_pass2_candidates( + &roots, + &asap_logical_asap_optimization::asap_aware_cse::sharing_candidates(&roots), + ) + .len(); + for (variant, roots) in identical_expression_variants(roots).into_iter().enumerate() + { + let widened = variant >= unwidened; + let Ok(physicals) = optimize_physical_asap( + "L", + &roots, + &entries, + &data, + models.deployment_capabilities, + ) else { + continue; + }; + for physical in physicals { + match priced_both( + &memo, &choice, &parts, &physical, &entries, &data, &models, prices, + &mut cache, + ) { + Some((expected, model, full)) => { + // The model's terms are a lower bound, and + // with the lazy terms the price itself. + assert!( + model <= expected * (1.0 + 1e-9), + "{queries:?} {:?}: model {model} over {expected}", + physical.assignment + ); + assert!( + (full - expected).abs() <= 1e-9 * expected.abs(), + "{queries:?} {:?}: {full} vs {expected}", + physical.assignment + ); + checked.push(physical.assignment.clone()); + } + None if widened => {} + None => unplaced.push(physical.assignment.clone()), + } + } + } + } + } + assert!( + unplaced.is_empty(), + "{queries:?}: {} plans whose choices are not found", + unplaced.len() + ); + checked + } + + /// Stage 3's price of `physical`, and the coefficients' (`None` when + /// a group's choice is not found in it). + #[allow(clippy::too_many_arguments)] + fn priced_both( + memo: &Memo, + choice: &[Option], + parts: &[Vec], + physical: &PhysicalCandidate, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: &PlanningModels<'_>, + prices: &crate::ResourceUnitCost, + cache: &mut PriceCache, + ) -> Option<(f64, f64, f64)> { + let settings = models.plan_selection; + let dag = &physical.dag; + let recurrence: Vec<_> = entries.iter().map(|e| e.recurrence.clone()).collect(); + let ranges: Vec<_> = entries.iter().map(|e| e.range_evaluation).collect(); + let nodes = node_map(dag); + let (cost, _) = price_at_scale( + dag, + &nodes, + &kept_pane_chains(dag), + &recurrence, + &ranges, + data, + settings, + 1.0, + ) + .ok()?; + let expected = cost.cost_per_second?; + + let rates = computations( + dag, + &recurrence, + &ranges, + settings.non_repeating_query_horizon_seconds, + None, + ) + .ok()?; + let reach = query_reach(dag, &nodes); + let keys = shallow_keys(dag, cache.keys_mut()); + let position_of: HashMap = dag + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id, i)) + .collect(); + let by_key = |key: u32| -> Vec { + dag.nodes + .iter() + .zip(&keys) + .filter(|(_, k)| **k == key) + .map(|(n, _)| n.id) + .collect() + }; + let query_rate = + |q: usize| crate::shared_evaluation_rate(&[&entries[q]], settings).unwrap(); + // Each node's producers, and the nodes below a node. + let producers = |id: PhysicalASAPNodeId| -> Vec { + dag.edges + .iter() + .filter(|e| e.consumer == id) + .map(|e| e.producer) + .collect() + }; + let consumers = + |id: PhysicalASAPNodeId| dag.edges.iter().filter(|e| e.producer == id).count(); + let below = |root: PhysicalASAPNodeId| -> Vec { + let mut seen = HashSet::new(); + let mut stack = vec![root]; + let mut out = Vec::new(); + while let Some(id) = stack.pop() { + if seen.insert(id) { + out.push(id); + stack.extend(producers(id)); + } + } + out + }; + // The queries whose plan reaches each node at all. + let starts = crate::query_starts(dag); + let reaches_at_all = |id: PhysicalASAPNodeId| -> Vec { + starts + .iter() + .enumerate() + .filter(|(_, start)| start.iter().any(|s| below(*s).contains(&id))) + .map(|(q, _)| q) + .collect() + }; + let kept = kept_pane_chains(dag); + // A scan's horizon in the plan, or `None` when one reader has it + // (apart, its reader's). + let scan_variant = |s: PhysicalASAPNodeId| -> Option { + (consumers(s) > 1).then(|| { + windows_read(dag, &nodes, s, &kept) + .into_iter() + .max() + .unwrap_or(0) + }) + }; + // Each needed group's choice in this plan: its candidate, with the + // plan's materialization of its private builds. + struct Placed<'p> { + part: &'p ChoiceParts, + root: PhysicalASAPNodeId, + /// Whether `root` is its query-time copy. + at_query_time: bool, + /// Its copies at ingestion time (under ingestion-time builds). + at_ingestion: Vec, + slots: Vec, + } + let mut placed: Vec = Vec::new(); + let mut used: HashSet = HashSet::new(); + // The plan's groups, parents first: the chosen ones, and the + // children their choices read that the memo's candidates do not + // list (their first candidate). + let mut chosen: Vec> = choice.to_vec(); + for g in (0..chosen.len()).rev() { + let Some(c) = chosen[g] else { continue }; + for part in parts[g].iter().filter(|p| p.choice.candidate == c) { + for child in part + .choice + .children + .iter() + .chain(part.choice.scan_children.iter().map(|(h, _)| h)) + { + chosen[child.0 as usize].get_or_insert(0); + } + } + } + for g in (0..chosen.len()).rev() { + let Some(c) = chosen[g] else { continue }; + let sample = parts[g].iter().find(|p| p.choice.candidate == c)?; + // The plan's copies of its node: equal shallow keys, reading + // equal private producers. + let group_queries = &memo.groups[g].queries; + // The plan's copies of its node, by any of its candidate's + // versions (thresholds): equal shallow keys, reading equal + // private producers. + let mut copies: Vec = Vec::new(); + for part in parts[g].iter().filter(|p| p.choice.candidate == c) { + for id in by_key(part.root_key) { + let keys_below: Vec = + producers(id).iter().map(|p| keys[position_of[p]]).collect(); + if !used.contains(&id) + && !copies.contains(&id) + && (part.choice.scan_horizon_ms.is_some() + || reaches_at_all(id).iter().any(|q| group_queries.contains(q))) + && part + .root_producer_keys + .iter() + .all(|k| keys_below.contains(k)) + { + copies.push(id); + } + } + } + if copies.is_empty() { + // A scan every reader has apart (its readers'). + if sample.choice.scan_horizon_ms.is_some() { + continue; + } + return None; + } + let timing = |id: &PhysicalASAPNodeId| nodes[id].output_state.timing; + let query_time: Vec = copies + .iter() + .copied() + .filter(|id| timing(id) == ExecutionTiming::QueryTime) + .collect(); + let at_ingestion: Vec = copies + .iter() + .copied() + .filter(|id| timing(id) == ExecutionTiming::IngestionTime) + .collect(); + let apart = query_time.len() > 1; + let roots: Vec<(PhysicalASAPNodeId, bool)> = match query_time.is_empty() { + true => vec![(at_ingestion[0], false)], + false => query_time.iter().map(|&id| (id, true)).collect(), + }; + for (k, &(root, at_query_time)) in roots.iter().enumerate() { + let copy_of = match apart { + true => Some( + *reaches_at_all(root) + .iter() + .find(|q| group_queries.contains(q))?, + ), + false => None, + }; + let mine = below(root); + let scans_below: Vec> = mine + .iter() + .filter(|id| { + matches!(nodes[id].payload, Operator::NonASAP(NonASAPOp::Scan { .. })) + }) + .filter(|id| { + producers(root).contains(id) + || mine.iter().any(|m| producers(*m).contains(id)) + }) + .map(|&s| scan_variant(s)) + .collect(); + let part = parts[g].iter().find(|p| { + p.choice.candidate == c + && p.choice.copy_of == copy_of + && p.build_keys + .iter() + .zip(&p.choice.assignment) + .all(|(key, m)| { + mine.iter().any(|id| { + keys[position_of[id]] == *key && nodes[id].materialization == *m + }) + }) + && p.choice + .scan_children + .iter() + .all(|(_, v)| scans_below.contains(v)) + && match p.choice.scan_horizon_ms { + None => true, + Some(h) => { + windows_read(dag, &nodes, root, &kept).into_iter().max() == Some(h) + } + } + }); + let part = part?; + // Apart scans below are this choice's. + for (_, v) in &part.choice.scan_children { + if v.is_none() { + for id in &mine { + if matches!( + nodes[id].payload, + Operator::NonASAP(NonASAPOp::Scan { .. }) + ) && consumers(*id) == 1 + { + used.insert(*id); + } + } + } + } + let slots = part + .slot_keys + .iter() + .map(|key| { + mine.iter() + .copied() + .find(|id| keys[position_of[id]] == *key) + }) + .collect::>>(); + let slots = slots?; + placed.push(Placed { + part, + root, + at_query_time, + at_ingestion: match k { + 0 => at_ingestion.clone(), + _ => Vec::new(), + }, + slots, + }); + } + } + let part_root = |p: &Placed, via: Via| match via { + Via::Direct => p.root, + Via::Slot(i) => p.slots[i], + }; + // Latency: each query's sum of the work of the parts it reaches. + let mut latency = vec![0.0; entries.len()]; + let mut counted_slots: HashSet = HashSet::new(); + for p in &placed { + let vias = std::iter::once(Via::Direct).chain((0..p.slots.len()).map(Via::Slot)); + for via in vias { + // A part's node several copies or groups share is one + // node, its work counted once. + if !counted_slots.insert(part_root(p, via)) { + continue; + } + let work = match via { + Via::Direct if !p.at_query_time => 0.0, + Via::Direct => p.part.choice.coefficients.direct.work_ms, + Via::Slot(i) => p.part.choice.coefficients.slots[i].work_ms, + }; + for (q, reached) in reach.iter().enumerate() { + if reached.contains(&part_root(p, via)) { + latency[q] += work; + } + } + } + } + let mut actual = 0.0; + let mut scans = Vec::new(); + // Shared nodes built once at query time: charged once, at their + // rate, in place of each copy's per-consumer charge. + let mut built_once_nodes: HashSet = HashSet::new(); + let mut held_extra = 0.0; + let mut charged_slots: HashSet = HashSet::new(); + let mut charged_roots: HashSet = HashSet::new(); + for p in &placed { + let coefficients = &p.part.choice.coefficients; + let mut lazy = p.part.lazy.clone(); + // Two groups whose chosen nodes are one node (identical after a + // shared build is resized): its consumers' rate is both groups', + // charged once. + let first_root = charged_roots.insert(p.root); + if !first_root { + lazy.held.retain(|term| term.via != Via::Direct); + lazy.raw_scans.retain(|(_, _, via)| *via != Via::Direct); + } + if p.at_query_time && first_root { + actual += coefficients.direct.cost_per_unit_rate * rates[&p.root].1; + let mine = below(p.root); + let mut matched: HashSet = HashSet::new(); + for (shared, sub) in coefficients + .shared_nodes + .iter() + .zip(&p.part.shared_node_keys) + { + // Charged once per plan node at its rate: the node is + // one node for the copies or groups sharing it, built + // once when it (or a node above it) is materialized. + let Some(node) = mine.iter().copied().find(|id| { + keys[position_of[id]] == sub[0] + && !matched.contains(id) + && nodes[id].output_state.timing == ExecutionTiming::QueryTime + }) else { + continue; + }; + matched.insert(node); + actual -= shared.cost_per_unit_rate * rates[&p.root].1; + let materialized = + nodes[&node].materialization != Materialization::NotMaterialized; + let part_held: Vec = lazy + .held + .iter() + .filter(|t| sub.contains(&t.node_key)) + .filter(|t| !(materialized && t.node_key == sub[0])) + .copied() + .collect(); + lazy.held.retain(|term| !sub.contains(&term.node_key)); + // Its scans below are computed as it is (once when it + // is built once, per consumer otherwise). + let scans_below: Vec = below(node) + .into_iter() + .filter(|id| { + matches!(nodes[id].payload, Operator::NonASAP(NonASAPOp::Scan { .. })) + }) + .collect(); + let materialized_here = + nodes[&node].materialization != Materialization::NotMaterialized; + for (&scan, &(_, cost, _)) in + scans_below.iter().zip(&coefficients.absorbed_scans) + { + if !materialized_here { + break; + } + actual -= cost * rates[&p.root].1; + // Its held memory, once at the scan's rate. + let scan_key = keys[position_of[&scan]]; + let scan_held: Vec = lazy + .held + .iter() + .filter(|t| t.node_key == scan_key) + .copied() + .collect(); + lazy.held.retain(|t| t.node_key != scan_key); + if built_once_nodes.insert(scan) { + actual += cost * rates[&scan].1; + let readers: Vec<(usize, f64)> = reach + .iter() + .enumerate() + .filter(|(_, r)| r.contains(&scan)) + .map(|(q, _)| (q, query_rate(q))) + .collect(); + let rate: f64 = readers.iter().map(|(_, r)| r).sum(); + for term in scan_held { + let evaluation_ms = match term.per_reader && rate > 0.0 { + true => { + readers.iter().map(|&(q, r)| r * latency[q]).sum::() + / rate + } + false => { + readers.iter().map(|&(q, _)| latency[q]).fold(0.0, f64::max) + } + }; + held_extra += prices.cost_per_memory_byte_second + * term.bytes as f64 + * rates[&scan].1 + * evaluation_ms + / 1_000.0; + } + } + } + if built_once_nodes.insert(node) { + actual += shared.cost_per_unit_rate * rates[&node].1; + let readers: Vec<(usize, f64)> = reach + .iter() + .enumerate() + .filter(|(_, r)| r.contains(&node)) + .map(|(q, _)| (q, query_rate(q))) + .collect(); + let rate: f64 = readers.iter().map(|(_, r)| r).sum(); + let longest = readers.iter().map(|&(q, _)| latency[q]).fold(0.0, f64::max); + for term in part_held { + let evaluation_ms = match term.per_reader && rate > 0.0 { + true => { + readers.iter().map(|&(q, r)| r * latency[q]).sum::() / rate + } + false => longest, + }; + held_extra += prices.cost_per_memory_byte_second + * term.bytes as f64 + * rates[&node].1 + * evaluation_ms + / 1_000.0; + } + if materialized { + held_extra += prices.cost_per_memory_byte_second + * shared.state_bytes as f64 + * rates[&node].1 + * longest + / 1_000.0; + } + } + } + } + for copy in &p.at_ingestion { + let at_ingestion = p.part.lazy.direct_at_ingestion?; + actual += at_ingestion.cost_per_unit_rate * rates[copy].1; + } + // A slot two copies share is one node, charged once, its held + // memory too. + for (i, slot) in coefficients.slots.iter().enumerate() { + if charged_slots.insert(p.slots[i]) { + let scans: f64 = slot.scans.iter().map(|&(_, cost, _)| cost).sum(); + actual += + slot.fixed_cost + (slot.cost_per_unit_rate + scans) * rates[&p.slots[i]].1; + } else { + lazy.held.retain(|term| term.via != Via::Slot(i)); + lazy.raw_scans.retain(|(_, _, via)| *via != Via::Slot(i)); + } + } + let built_once = nodes[&p.root].materialization != Materialization::NotMaterialized + && !is_build(&nodes[&p.root].payload) + && first_root; + // Only a query-time direct part holds memory or scans raw data. + if !p.at_query_time { + lazy.held.retain(|term| term.via != Via::Direct); + lazy.raw_scans.retain(|(_, _, via)| *via != Via::Direct); + } + held_extra += held_cost( + &lazy, + built_once, + |via| rates[&part_root(p, via)].1, + |via| { + reach + .iter() + .enumerate() + .filter(|(_, r)| r.contains(&part_root(p, via))) + .map(|(q, _)| (q, query_rate(q))) + .collect() + }, + |q| latency[q], + prices.cost_per_memory_byte_second, + ); + for (source, bytes, via) in &lazy.raw_scans { + if nodes[&part_root(p, *via)].output_state.timing == ExecutionTiming::QueryTime { + scans.push((source.clone(), *bytes)); + } + } + } + let model = actual; + let full = model + + held_extra + + raw_data_bytes(&scans) + .values() + .map(|&bytes| prices.cost_per_disk_byte_second * bytes as f64) + .sum::(); + Some((expected, model, full)) + } + + #[test] + fn coefficients_reproduce_stage_3_on_one_query_plans() { + for query in [ + "sum by (job) (rate(m[5m]))", + "max(avg_over_time(m[10m]))", + "sum_over_time(m[5m])", + ] { + assert!(!check(&[query], &[60_000]).is_empty(), "{query}"); + } + } + + /// A group both queries read: not materialized, built once at query + /// time, and its builds at ingestion time, in every plan. + #[test] + fn coefficients_reproduce_stage_3_with_a_shared_group() { + let checked = check( + &[ + "sum by (job) (rate(m[5m]))", + "sum by (job) (rate(m[5m])) * 2", + ], + &[60_000, 30_000], + ); + let at = |m: fn(&Materialization) -> bool| checked.iter().flatten().any(m); + assert!(at(|m| matches!(m, Materialization::MatAtQuery(_)))); + assert!(at(|m| matches!(m, Materialization::MatAtIngestion(_)))); + } + + /// A 5-minute sum read from its own panes or from a 1-hour sum's + /// chain (the chain site's thresholds), built at query or ingestion + /// time. + #[test] + fn coefficients_reproduce_stage_3_with_a_shared_pane_chain() { + let checked = check( + &["sum_over_time(m[5m])", "sum_over_time(m[1h])"], + &[60_000, 60_000], + ); + assert!(checked.len() > 2); + } + + /// Three queries over one metric: a shared rate under two + /// aggregations, and a sum over a longer window of the same scan. + #[test] + fn coefficients_reproduce_stage_3_on_a_mixed_workload() { + let checked = check( + &[ + "sum by (job) (rate(m[5m]))", + "max(rate(m[5m]))", + "sum_over_time(m[10m])", + ], + &[60_000, 30_000, 60_000], + ); + assert!(!checked.is_empty()); + } + + /// A per-job sum and the total rolled up from it. (TODO: a sketch + /// rollup, which reads its finer group's build rather than its node; + /// the test does not place those plans yet.) + #[test] + fn coefficients_reproduce_stage_3_with_a_rollup() { + assert!(!check( + &["sum by (job) (rate(m[5m]))", "sum(rate(m[5m]))"], + &[60_000, 60_000], + ) + .is_empty()); + } + + /// The model restricted to each group choice ([`super::super::milp::lower_bound_check`]) + /// is a lower bound of the group choice's exact price. + fn assert_model_is_a_lower_bound( + queries: &[&str], + intervals_ms: &[u32], + targets: &[AccuracyTarget], + ) { + assert_model_is_a_lower_bound_in(queries, intervals_ms, targets, true); + assert_model_is_a_lower_bound_in(queries, intervals_ms, targets, false); + } + + /// [`assert_model_is_a_lower_bound`] in a deployment that keeps raw + /// data or not. + fn assert_model_is_a_lower_bound_in( + queries: &[&str], + intervals_ms: &[u32], + targets: &[AccuracyTarget], + raw_data_retained: bool, + ) { + let roots: Vec<(usize, QueryRoot)> = queries + .iter() + .zip(targets) + .map(|(q, target)| { + let root = lower_promql(q, target.clone()); + QueryRoot::Operator(with_promql_series_identity(&root).unwrap()) + }) + .enumerate() + .collect(); + let per_computation = generate_per_computation_candidates(roots).unwrap(); + let entries = entries(intervals_ms, targets); + let data = data(); + let deployment = + asap_logical_asap_optimization::deployment_capabilities::DeploymentCapabilities { + raw_data_retained, + query_time_results_retained: false, + ..Default::default() + }; + let models = PlanningModels::builtin().with_deployment_capabilities(&deployment); + let memo = memo(&per_computation, &entries); + let input = super::super::milp::model_input_of(&memo, &entries, &data, models).unwrap(); + let checked = super::super::milp::lower_bound_check( + &memo, + &per_computation, + &input, + &entries, + &data, + models, + ) + .unwrap(); + // Without raw data a workload may have no valid plan at all. + assert!(!raw_data_retained || !checked.is_empty()); + for (group_choice, exact, value) in checked { + let value = value.unwrap_or_else(|| panic!("{queries:?} {group_choice:?}: infeasible")); + assert!( + value <= exact * (1.0 + 1e-9), + "{queries:?} {group_choice:?}: model {value} over exact {exact}" + ); + } + } + + /// Three quantiles of one metric over a 1-minute, a 5-minute and an + /// instant window (#840's shape), every 10 s; and a shared group with + /// a rollup. + #[test] + fn the_model_is_a_lower_bound_of_every_group_choice() { + let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); + assert_model_is_a_lower_bound( + &[ + "quantile_over_time(0.5, m[1m])", + "quantile_over_time(0.9, m[5m])", + "quantile by (job) (0.5, m)", + ], + &[10_000, 10_000, 10_000], + &[target.clone(), target.clone(), target], + ); + let exact = AccuracyTarget::exact(); + assert_model_is_a_lower_bound( + &[ + "sum by (job) (rate(m[5m]))", + "sum(rate(m[5m]))", + "sum_over_time(m[10m])", + ], + &[60_000, 30_000, 60_000], + &[exact.clone(), exact.clone(), exact], + ); + } + + /// #839's shape: sums over 10 minutes by job and in total (a rollup), + /// and two 100-second quantiles over scans of one metric projecting + /// different labels (a widened scan shares them). + #[test] + fn the_model_is_a_lower_bound_with_rollups_and_widened_scans() { + let target = AccuracyTarget::max_error(0.05).failure_probability(0.3); + assert_model_is_a_lower_bound( + &[ + "sum by (job) (sum_over_time(m[10m]))", + "sum(sum_over_time(m[10m]))", + "avg by (job) (quantile_over_time(0.99, m[100s]))", + "avg(quantile_over_time(0.95, m[100s]))", + ], + &[20_000, 20_000, 1_000, 1_000], + &[target.clone(), target.clone(), target.clone(), target], + ); + } + + /// One query reading two groups over one metric, whose scans may be one + /// node: its latency and held memory stay lower bounds. + #[test] + fn the_model_is_a_lower_bound_for_a_query_over_two_groups_of_one_metric() { + let exact = AccuracyTarget::exact(); + assert_model_is_a_lower_bound( + &[ + "max_over_time(m[5m]) - min_over_time(m[5m])", + "max_over_time(m[5m])", + ], + &[60_000, 60_000], + &[exact.clone(), exact], + ); + } + + /// #840's rollup: a quantile by host and job and its rollup by job, at + /// the rollup's state-size thresholds. + #[test] + fn the_model_is_a_lower_bound_with_a_sketch_rollup() { + let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); + assert_model_is_a_lower_bound( + &[ + "quantile by (host, job) (0.5, m)", + "quantile by (job) (0.5, m)", + ], + &[10_000, 10_000], + &[target.clone(), target], + ); + } + + /// Two quantiles within 1% and 2% sharing one KLL build at a size + /// threshold. + #[test] + fn coefficients_reproduce_stage_3_with_a_shared_build() { + let checked = check_with( + &[ + "quantile_over_time(0.9, m[5m])", + "quantile_over_time(0.9, m[5m])", + ], + &[60_000, 60_000], + &[ + AccuracyTarget::max_error(0.01), + AccuracyTarget::max_error(0.02), + ], + ); + assert!(!checked.is_empty()); + } +} diff --git a/crates/plan-selection/src/pass/subexpr/milp.rs b/crates/plan-selection/src/pass/subexpr/milp.rs new file mode 100644 index 000000000..4cc900d35 --- /dev/null +++ b/crates/plan-selection/src/pass/subexpr/milp.rs @@ -0,0 +1,2276 @@ +//! The memo's solver: one MILP over the memo's choices (design "The +//! solver: MILP over the memo"). +//! +//! A choice is one candidate of one group with an option for each of its +//! private materialization slots ([`Choice`]); its coefficients come from +//! Stage 3's per-node pricing ([`ChoiceCoefficients`]). The model picks one +//! choice per needed group, a materialization for each shared group's own +//! node, and propagates which consumers (queries, materialized slots) reach +//! which group, so each private node is charged once per consumer reaching +//! it, at that consumer's rate. Terms the model omits (memory held while a +//! query runs, composed accuracy) are added back by the caller's lazy cuts: +//! the model's optimum is a lower bound on the plan's Stage 3 price. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use good_lp::solvers::highs::highs; +use good_lp::{ + constraint, variable, Expression, ProblemVariables, Solution, SolverModel, Variable, WithMipGap, +}; + +use super::coefficients::LazyTerms; +use super::{GroupId, Memo}; +use crate::{CostModel, PlanSelector, PlanningModels, ResourceUnitCost, Selection, SelectionError}; +use asap_logical_asap_optimization::per_computation_candidates::PerComputationCandidates; +use asap_physical_asap_optimization::PhysicalCandidate; +use asap_types::ir::QueryRoot; +use asap_types::physical::Materialization; +use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; + +/// How a child group of a choice is reached: by the consumers reaching the +/// choice's own node, or by one of its materialized private slots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Via { + Direct, + Slot(usize), +} + +/// What one part of a choice costs, from Stage 3's per-node pricing. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(crate) struct PartCoefficients { + /// Price per second per unit of the rate of a consumer reaching it. + pub cost_per_unit_rate: f64, + /// Work in ms per computation, which a query reaching it at query time + /// adds to its latency. + pub work_ms: f64, +} + +/// One materialized private slot of a choice. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlotCoefficients { + pub materialization: Materialization, + /// Price per second that does not depend on a rate (ingestion-time + /// work and kept state). + pub fixed_cost: f64, + /// Price per second per unit of the slot's own rate (query-time slots: + /// the union rate of the queries reaching the group). + pub cost_per_unit_rate: f64, + /// Work in ms per computation at query time. + pub work_ms: f64, + /// The structural key of the slot's build over its inputs (and a + /// threshold index, 0): the slots of chosen choices with the same key + /// and materialization are one node when shared (a site's build at one + /// threshold, identical expressions), so the model charges them once. + pub shared_site: Option<(u64, usize)>, + /// A query-time slot's raw scans, out of `cost_per_unit_rate`: like + /// [`ChoiceCoefficients::absorbed_scans`], charged as scans of their + /// source (another slot's or part's identical scan is one node). + pub scans: Vec<(u64, f64, f64)>, +} + +/// A cross-group node's variables: its once charge, its charge, the +/// binary choosing between per consumer and once, its members' once +/// charges, its per-consumer charge and their big M. +type CrossNodeVars = ( + Variable, + Variable, + Variable, + Vec<(usize, f64)>, + Expression, + f64, +); + +/// One choice's coefficients. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ChoiceCoefficients { + pub direct: PartCoefficients, + pub slots: Vec, + /// Per child group of the candidate, in order. + pub child_via: Vec, + /// The shareable nodes of the direct part (an ASAP node, or one whose + /// schema has a unique key) of a group two or more queries read, other + /// than its own node: each may be one node for every copy of the group + /// (the identical-expression rule), and then built once at query time. + pub shared_nodes: Vec, + /// Shareable nodes another group's choices may also have (equal keys): + /// one node once shared, so charged once per key, at the dearest of + /// the chosen ones (key, price per unit of rate). Moved out of + /// `direct` and `shared_nodes` by the model's input. + pub cross_shared: Vec<(u64, f64)>, + /// The query-time scans of the direct part (apart: this choice's own + /// copies), as (source key, price, a pass over its output) per unit of + /// rate. Scans of one source may be one widened scan: like the scan + /// choices (`Choice::scan_source`), each is charged its pass, and the + /// largest excess once per source. Moved out of `direct` by the + /// model's input. + pub absorbed_scans: Vec<(u64, f64, f64)>, + /// Each query-time node of the direct part but scans, as (key up to + /// scan widening, price per unit of rate). Nodes of several groups' + /// choices with one key are one node once shared (identical + /// expressions, scans widened to one label set): the model's input + /// moves them to `cross_shared`. + pub direct_nodes: Vec<(u64, f64)>, +} + +/// A shareable node of a choice's direct part ([`ChoiceCoefficients::shared_nodes`]). +/// Its price is in `direct.cost_per_unit_rate`, charged per consumer; built +/// once at query time it costs `cost_per_unit_rate` per unit of the union +/// rate of the queries reaching it instead, and holds `state_bytes` while +/// its longest reader runs. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SharedNodeCoefficients { + pub cost_per_unit_rate: f64, + pub work_ms: f64, + pub state_bytes: u64, + /// The widened keys of the nodes it covers ([`ChoiceCoefficients::direct_nodes`]), + /// itself first. + pub members: Vec, +} + +/// One choice of a group: a candidate and an option per private slot. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Choice { + pub group: GroupId, + pub candidate: usize, + /// The groups it implements (the first is `group`). + pub implements: Vec, + /// The child groups it reads. + pub children: Vec, + pub assignment: Vec, + pub coefficients: ChoiceCoefficients, + /// `None`: the group's one node, shared by every query reading it. + /// `Some(q)`: query `q`'s own copy of it (the group apart); a group's + /// copies are all apart or all shared. + pub copy_of: Option, + /// Each child group that is a scan: `None` when it is apart and its + /// copy is this choice's own (not in `children`); `Some(h)` when it is + /// the scan shared at horizon `h` ms (its choice's `scan_horizon_ms`). + pub scan_children: Vec<(GroupId, Option)>, + /// For a scan group's choice: the horizon it reads, the longest window + /// among its readers. + pub scan_horizon_ms: Option, + /// For a scan group's choice: its source's raw-horizon key. Scans of + /// one source may be one widened scan (the identical-expression rule + /// over scans widened to one label set), which costs at least the + /// dearest of them: the model charges the most expensive scan choice + /// of each source, not their sum. + pub scan_source: Option, + /// For a scan group's choice: what a pass over its output costs per + /// unit of rate, at most its own price. A widened scan shared by a + /// source's scan groups costs at least the dearest scan, and each other + /// reader at least such a pass: the model charges each distinct scan's + /// pass once (readers of one scan share its node), plus the largest + /// excess over it. + pub scan_project_cost_per_unit_rate: f64, +} + +/// A shared group's own node, built once per evaluation when chosen. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct GroupSlot { + pub group: GroupId, + /// Whether it may stay `NotMaterialized` (recomputed per consumer). + pub may_recompute: bool, +} + +/// One query of the model. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ModelQuery { + /// Its evaluations per second. + pub rate: f64, + /// The groups its root reads directly (one for an operator root). + pub roots: Vec, + /// Its latency bound in ms, if any (the tighter of its own bound and + /// its repeat interval). + pub max_latency_ms: Option, +} + +/// Everything the model reads. +pub(crate) struct ModelInput { + pub group_count: usize, + /// Per group, the queries whose plan may reach it. + pub group_queries: Vec>, + pub choices: Vec, + pub group_slots: Vec, + pub queries: Vec, + /// Per query, its schedule class: queries of one class run at the same + /// instants (one recurrence and range evaluation). + pub schedule: Vec, + /// The union rate of the classes in each set, by sorted class set: how + /// often a node built once for queries of those classes is computed. + pub union_rate: HashMap, f64>, + /// No-good cuts from earlier iterations: at most `bound` of these + /// choices together (a plan whose every group took one of them). + /// With the groups that plan does not need: a plan needing any of them + /// is another plan, not cut. + pub excluded: Vec<(Vec, Vec, f64)>, + /// Lower bounds on the omitted cost, added as `extra ≥ bound` when all + /// of a choice set is chosen (local cuts). + pub extra_bounds: Vec<(Vec, f64)>, + /// Requirements of a choice on another group's choice: when the first + /// is chosen, one of the second (an exact implementation, or one + /// carrying a coupling site) is. + pub requires: Vec<(usize, Vec)>, + /// Choices that may not be chosen together with any of the second set + /// (panes and an exact top-k reuse in one plan). + pub exclusive: (Vec, Vec), + /// The rollup requirements ([`super::windows::rollup_rules`]): when a + /// choice of each set of the first list is chosen (each set a + /// candidate's choices), one of the second is. + pub rollup_rules: Vec<(Vec>, Vec)>, + /// Each query-time raw scan of a choice's part: each source's longest + /// one is kept on disk. + pub raw_scans: Vec, + /// Whether the deployment keeps raw data: without it, no part with a + /// raw scan may run at query time. + pub raw_data_retained: bool, + /// Per choice, the memory its parts hold while a query's evaluation + /// runs ([`HeldAtQueryTime`]). + pub held: Vec, + /// The solver's time limit in seconds, if any. + pub time_limit_s: Option, + /// Solve the linear relaxation (every binary in [0, 1]): a lower bound + /// on the model's optimum. + pub relaxed: bool, +} + +/// The memory a choice's parts hold while an evaluation runs, priced per +/// ms of latency: Stage 3 charges `bytes · rate · E / 1000` at the memory +/// price, `E` the latency of the queries reaching the part. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(crate) struct HeldAtQueryTime { + /// Per unit of a reading query's rate: each reader holds its own copy + /// over its own latency. + pub per_reader_per_ms: f64, + /// Per unit of rate: one copy held over the longest reader's latency. + pub longest_per_ms: f64, +} + +/// A raw scan of one part of a choice, when that part runs at query time. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RawScan { + pub choice: usize, + /// Index of its source. + pub source: usize, + /// The price per second of keeping its look-back on disk. + pub cost: f64, + pub via: Via, +} + +/// The memory `choice`'s parts hold while an evaluation runs +/// ([`LazyTerms::held`]), but its ingestion-time slots'. Its own node, +/// when built once, holds the smaller of its working memory and its built +/// state. +fn held_at_query_time( + choice: &Choice, + lazy: &LazyTerms, + prices: &ResourceUnitCost, + shared_keys: &BTreeSet<(u64, usize)>, +) -> HeldAtQueryTime { + let mut held = HeldAtQueryTime::default(); + for term in &lazy.held { + if let Via::Slot(i) = term.via { + let slot = &choice.coefficients.slots[i]; + if matches!(slot.materialization, Materialization::MatAtIngestion(_)) + || slot + .shared_site + .is_some_and(|key| shared_keys.contains(&key)) + { + continue; + } + } + let bytes = match term.own_node { + true => term.bytes.min(lazy.built_once_state_bytes), + false => term.bytes, + }; + let per_ms = prices.cost_per_memory_byte_second * bytes as f64 / 1000.0; + match term.per_reader { + true => held.per_reader_per_ms += per_ms, + false => held.longest_per_ms += per_ms, + } + } + held +} + +/// The model's optimum. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ModelSolution { + /// The chosen choices, by index in [`ModelInput::choices`]. + pub chosen: Vec, + /// The shared groups built once per evaluation. + pub built_once: BTreeSet, + /// The model's objective: a lower bound on the plan's price. + pub objective: f64, + /// Whether the solver proved it optimal (not stopped at its time + /// limit): only then is `objective` a lower bound. + pub optimal: bool, + /// The model's size. + pub variables: u64, + pub constraints: u64, +} + +/// A consumer of the model: a query, a materialized private slot of a +/// choice, or a shared group built once. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum Consumer { + Query(usize), + Slot { choice: usize, slot: usize }, + Group(GroupId), +} + +/// Build and solve the model. `None` when it is infeasible. +pub(crate) fn solve(input: &ModelInput) -> Result, String> { + // Prices per second are tiny (1e-6 and less) next to the solver's + // feasibility tolerance (1e-7): solve in units where the largest + // per-choice charge is about 1, and convert the objective back. + let scale = price_scale(input); + let scaled = scaled(input, scale); + let solution = solve_scaled(&scaled)?; + Ok(solution.map(|mut solution| { + solution.objective /= scale; + solution + })) +} + +/// How far below its objective a solve stopped at the solver's gap +/// tolerance (a relative gap of about 1e-6 at most) may have its optimum. +const GAP_TOLERANCE: f64 = 1e-4; + +/// The factor that brings `input`'s largest charge per evaluation to about +/// 1: each choice's charges at the fastest query's rate, and raw scans. +/// A slot's fixed cost (its kept state) is left out: a huge one would +/// shrink every other charge below the solver's tolerances (about 1e-7), +/// where it no longer tells charges apart and stops above the optimum. +fn price_scale(input: &ModelInput) -> f64 { + let fastest = input.queries.iter().map(|q| q.rate).fold(0.0, f64::max); + let largest = input + .choices + .iter() + .flat_map(|choice| { + let k = &choice.coefficients; + std::iter::once(k.direct.cost_per_unit_rate * fastest) + .chain(k.slots.iter().map(move |s| s.cost_per_unit_rate * fastest)) + }) + .chain(input.raw_scans.iter().map(|s| s.cost)) + .filter(|v| v.is_finite()) + .fold(0.0, f64::max); + if largest > 0.0 { + 1.0 / largest + } else { + 1.0 + } +} + +/// `input` with every price multiplied by `scale`. +fn scaled(input: &ModelInput, scale: f64) -> ModelInput { + let mut scaled = model_input_clone(input); + for choice in &mut scaled.choices { + let k = &mut choice.coefficients; + k.direct.cost_per_unit_rate *= scale; + for slot in &mut k.slots { + slot.fixed_cost *= scale; + slot.cost_per_unit_rate *= scale; + for (_, cost, pass) in &mut slot.scans { + *cost *= scale; + *pass *= scale; + } + } + for node in &mut k.shared_nodes { + node.cost_per_unit_rate *= scale; + } + choice.scan_project_cost_per_unit_rate *= scale; + for (_, cost) in &mut choice.coefficients.cross_shared { + *cost *= scale; + } + for (_, cost, pass) in &mut choice.coefficients.absorbed_scans { + *cost *= scale; + *pass *= scale; + } + } + for scan in &mut scaled.raw_scans { + scan.cost *= scale; + } + for held in &mut scaled.held { + held.per_reader_per_ms *= scale; + held.longest_per_ms *= scale; + } + for (_, bound) in &mut scaled.extra_bounds { + *bound *= scale; + } + scaled +} + +fn solve_scaled(input: &ModelInput) -> Result, String> { + let mut vars = ProblemVariables::new(); + let n = input.group_count; + let relaxed = input.relaxed; + let binary = || { + if relaxed { + variable().min(0).max(1) + } else { + variable().binary() + } + }; + let x: Vec = input.choices.iter().map(|_| vars.add(binary())).collect(); + let need: Vec = (0..n).map(|_| vars.add(binary())).collect(); + let once: BTreeMap = input + .group_slots + .iter() + .map(|slot| (slot.group, vars.add(binary()))) + .collect(); + // Which queries' plans reach each group at all (any materialization): + // the union rate of a node built once reads them. + let mut reaches_at_all: HashMap<(usize, usize), Variable> = HashMap::new(); + for (g, queries) in input.group_queries.iter().enumerate() { + for &q in queries { + reaches_at_all.insert((q, g), vars.add(binary())); + } + } + // Consumers that may reach each group's own node, and through it its + // direct part. + let consumers = possible_consumers(input); + let mut reach: HashMap<(Consumer, usize), Variable> = HashMap::new(); + for (&g, ks) in &consumers { + for &k in ks { + reach.insert((k, g), vars.add(variable().min(0).max(1))); + } + } + // A consumer passes through a group into its chosen direct part when + // it reaches the group and the group is not built once (or it is the + // group's own built-once consumer). + let mut passes: HashMap<(Consumer, usize), Variable> = HashMap::new(); + for &(k, g) in reach.keys() { + passes.insert((k, g), vars.add(variable().min(0).max(1))); + } + for &g in once.keys() { + let k = Consumer::Group(g); + passes + .entry((k, g.0 as usize)) + .or_insert_with(|| vars.add(variable().min(0).max(1))); + } + // y[k, c]: consumer k passes into choice c's direct part. + let mut y: HashMap<(Consumer, usize), Variable> = HashMap::new(); + for (c, choice) in input.choices.iter().enumerate() { + let g = choice.group.0 as usize; + for &(k, h) in passes.keys() { + if h == g { + y.insert((k, c), vars.add(variable().min(0).max(1))); + } + } + } + // Union-rate selectors for built-once consumers (shared groups and + // query-time private slots): one per set of schedule classes among the + // group's queries, exactly the classes of the queries reaching it. + let mut rate_of: HashMap, Variable)>> = HashMap::new(); + let classes_of = |g: usize| -> Vec { + let mut classes: Vec = input.group_queries[g] + .iter() + .map(|&q| input.schedule[q]) + .collect(); + classes.sort_unstable(); + classes.dedup(); + classes + }; + let class_sets = |g: usize| -> Vec> { + let classes = classes_of(g); + (1u64..(1 << classes.len())) + .map(|mask| { + (0..classes.len()) + .filter(|i| mask >> i & 1 == 1) + .map(|i| classes[i]) + .collect() + }) + .collect() + }; + let mut groups_with_rates: BTreeSet = once.keys().map(|g| g.0 as usize).collect(); + for choice in &input.choices { + if !choice.coefficients.shared_nodes.is_empty() + || choice + .coefficients + .slots + .iter() + .any(|s| !matches!(s.materialization, Materialization::MatAtIngestion(_))) + { + groups_with_rates.insert(choice.group.0 as usize); + } + } + for &g in &groups_with_rates { + let sets = class_sets(g) + .into_iter() + .map(|set| (set, vars.add(binary()))) + .collect(); + rate_of.insert(g, sets); + } + + // The objective. + let mut objective = Expression::from(0.0); + let mut products_pending: Vec<(Variable, Variable, Variable)> = Vec::new(); + // A consumer's constant rate, or `None` for one built once at query + // time, whose rate is its group's union rate. + let rate = |k: Consumer| -> Option { + match k { + Consumer::Query(q) => Some(input.queries[q].rate), + Consumer::Slot { choice, slot } => { + let m = input.choices[choice].coefficients.slots[slot].materialization; + matches!(m, Materialization::MatAtIngestion(_)).then_some(1.0) + } + Consumer::Group(_) => None, + } + }; + // 0 for a group no query reads (no rate is charged linearly then). + let slowest_rate = |g: usize| -> f64 { + rate_of + .get(&g) + .into_iter() + .flatten() + .map(|(set, _)| input.union_rate[set]) + .reduce(f64::min) + .unwrap_or(0.0) + }; + // Products of a variable rate (a set selector) and a reach variable. + let mut rated: Vec<(Variable, Variable, f64)> = Vec::new(); + // The shareable nodes of a choice's direct part cost, per unit of + // rate, `shared_cost[c]`, charged either per consumer (the rest of the + // direct part's charge) or once at the union rate of the queries + // reaching the group when built once: the model charges the smaller + // (`per_consumer_shared[c]` against `built_once_shared[c]`). + let shared_cost: Vec = input + .choices + .iter() + .map(|choice| { + choice + .coefficients + .shared_nodes + .iter() + .map(|node| node.cost_per_unit_rate) + .sum() + }) + .collect(); + let mut per_consumer_shared: Vec> = vec![Vec::new(); input.choices.len()]; + let mut built_once_shared: Vec> = vec![Vec::new(); input.choices.len()]; + let mut product = |vars: &mut ProblemVariables, a: Variable, b: Variable| { + let z = vars.add(variable().min(0).max(1)); + products_pending.push((z, a, b)); + z + }; + // Each scan choice's direct charge, per source (`Choice::scan_source`). + let mut scan_charges: BTreeMap> = BTreeMap::new(); + // Each scan choice's pass, per identical scan (its source and price): + // readers of one scan share its node and pay its pass once. + let mut pass_charges: BTreeMap<(u64, u64), BTreeMap<(usize, usize), Expression>> = + BTreeMap::new(); + // Each choice's direct charge per schedule class of its query + // consumers: one node serves the class's queries. + let mut class_charges: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); + // Each cross-group node's charges per consumer, and their largest sum. + let mut cross_per_consumer: BTreeMap = BTreeMap::new(); + for (&(k, c), &v) in &y { + let total = input.choices[c].coefficients.direct.cost_per_unit_rate; + let rate_or_slowest = match rate(k) { + Some(r) => r, + None => slowest_rate(match k { + Consumer::Group(g) => g.0 as usize, + Consumer::Slot { choice, .. } => input.choices[choice].group.0 as usize, + Consumer::Query(_) => unreachable!("a query's rate is constant"), + }), + }; + for &(key, cost) in &input.choices[c].coefficients.cross_shared { + let entry = cross_per_consumer + .entry(key) + .or_insert_with(|| (Expression::from(0.0), 0.0)); + entry.0 += rate_or_slowest * cost * v; + entry.1 += rate_or_slowest * cost; + } + for &(source, cost, pass) in &input.choices[c].coefficients.absorbed_scans { + let pass = pass.min(cost); + *pass_charges + .entry((source, cost.to_bits())) + .or_default() + .entry((c, 0)) + .or_insert_with(|| Expression::from(0.0)) += rate_or_slowest * pass * v; + *scan_charges + .entry(source) + .or_default() + .entry((c, 0)) + .or_insert_with(|| Expression::from(0.0)) += rate_or_slowest * (cost - pass) * v; + } + if total == 0.0 { + continue; + } + if let Some(source) = input.choices[c].scan_source { + let rate = rate_or_slowest; + let pass = input.choices[c].scan_project_cost_per_unit_rate.min(total); + *pass_charges + .entry((source, total.to_bits())) + .or_default() + .entry((c, 0)) + .or_insert_with(|| Expression::from(0.0)) += rate * pass * v; + *scan_charges + .entry(source) + .or_default() + .entry((c, 0)) + .or_insert_with(|| Expression::from(0.0)) += rate * (total - pass) * v; + continue; + } + let shared = shared_cost[c].min(total); + let cost = total - shared; + match rate(k) { + Some(r) => { + match k { + // Queries of one class read one node of the choice. + Consumer::Query(q) => class_charges + .entry((c, input.schedule[q])) + .or_default() + .push(r * cost * v), + _ => objective += r * cost * v, + } + if shared != 0.0 { + per_consumer_shared[c].push((v, r * shared)); + } + } + None => { + let g = match k { + Consumer::Group(g) => g.0 as usize, + Consumer::Slot { choice, .. } => input.choices[choice].group.0 as usize, + Consumer::Query(_) => unreachable!("a query's rate is constant"), + }; + // At least the slowest class's rate is charged linearly + // (some query reaches the group), the rest through the + // selector products: a tighter relaxation. + let slowest = slowest_rate(g); + objective += slowest * cost * v; + for (set, selector) in &rate_of[&g] { + let union = input.union_rate[set]; + if cost != 0.0 && union > slowest { + rated.push((*selector, v, (union - slowest) * cost)); + } + if shared != 0.0 { + let z = product(&mut vars, *selector, v); + per_consumer_shared[c].push((z, union * shared)); + } + } + } + } + } + // Query-time slots' scans, at the slot's group's slowest rate, each + // slot's apart from its choice's other parts. + for (c, choice) in input.choices.iter().enumerate() { + let slowest = slowest_rate(choice.group.0 as usize); + for (i, slot) in choice.coefficients.slots.iter().enumerate() { + if matches!(slot.materialization, Materialization::MatAtIngestion(_)) { + continue; + } + for &(source, cost, pass) in &slot.scans { + let pass = pass.min(cost); + *pass_charges + .entry((source, cost.to_bits())) + .or_default() + .entry((c, i + 1)) + .or_insert_with(|| Expression::from(0.0)) += slowest * pass * x[c]; + *scan_charges + .entry(source) + .or_default() + .entry((c, i + 1)) + .or_insert_with(|| Expression::from(0.0)) += slowest * (cost - pass) * x[c]; + } + } + } + let mut scan_maxima: Vec<(Variable, Vec)> = Vec::new(); + for charges in class_charges.into_values() { + let m = vars.add(variable().min(0)); + objective += m; + scan_maxima.push((m, charges)); + } + for charges in scan_charges.into_values().chain(pass_charges.into_values()) { + let m = vars.add(variable().min(0)); + objective += m; + scan_maxima.push((m, charges.into_values().collect())); + } + for (c, choice) in input.choices.iter().enumerate() { + if shared_cost[c] == 0.0 { + continue; + } + for (set, selector) in &rate_of[&(choice.group.0 as usize)] { + let z = product(&mut vars, *selector, x[c]); + built_once_shared[c].push((z, input.union_rate[set] * shared_cost[c])); + } + } + // min(A, B) as a + b with a ≥ A − M·u, b ≥ B − M·(1 − u), u binary. + let mut cheaper_of: Vec<(Variable, Variable, Variable, usize, f64)> = Vec::new(); + for c in 0..input.choices.len() { + if per_consumer_shared[c].is_empty() && built_once_shared[c].is_empty() { + continue; + } + let big_m: f64 = per_consumer_shared[c].iter().map(|(_, w)| w).sum::() + + built_once_shared[c].iter().map(|(_, w)| w).sum::(); + let a = vars.add(variable().min(0)); + let b = vars.add(variable().min(0)); + let u = vars.add(binary()); + objective += a + b; + objective += shared_cost[c] * slowest_rate(input.choices[c].group.0 as usize) * x[c]; + cheaper_of.push((a, b, u, c, big_m)); + } + // Slots that may be one shared node (equal builds over equal inputs): + // charged once, at the dearest of the chosen ones' charges with the + // slowest class's rate. One node shared by them costs at least each + // (it reads the longest of their windows), and apart they cost their + // sum. + let mut shared_slots: BTreeMap<(u64, usize, String), Vec<(usize, f64)>> = BTreeMap::new(); + for (c, choice) in input.choices.iter().enumerate() { + for slot in &choice.coefficients.slots { + let Some((site, threshold)) = slot.shared_site else { + continue; + }; + let at_query = !matches!(slot.materialization, Materialization::MatAtIngestion(_)); + let charge = slot.fixed_cost + + match at_query { + true => slowest_rate(choice.group.0 as usize) * slot.cost_per_unit_rate, + false => 0.0, + }; + shared_slots + .entry((site, threshold, format!("{:?}", slot.materialization))) + .or_default() + .push((c, charge)); + } + } + // A node several groups' choices have (`cross_shared`) costs, in the + // plan, its charge per consumer (one node computed per consumer, or a + // copy per group) or, built once, at least the dearest chosen one's + // charge at its group's slowest rate: the model charges the smaller. + let mut cross_once: BTreeMap> = BTreeMap::new(); + for (c, choice) in input.choices.iter().enumerate() { + for &(key, cost) in &choice.coefficients.cross_shared { + cross_once + .entry(key) + .or_default() + .push((c, slowest_rate(choice.group.0 as usize) * cost)); + } + } + let mut cross_vars: Vec = Vec::new(); + for (key, members) in cross_once { + let (per_consumer, per_consumer_max) = cross_per_consumer + .remove(&key) + .unwrap_or_else(|| (Expression::from(0.0), 0.0)); + let big_m = per_consumer_max + members.iter().map(|(_, w)| *w).fold(0.0, f64::max); + let once = vars.add(variable().min(0)); + let charged = vars.add(variable().min(0)); + let u = vars.add(binary()); + objective += charged; + cross_vars.push((once, charged, u, members, per_consumer, big_m)); + } + let mut shared_slot_vars: Vec<(Variable, Vec<(usize, f64)>)> = Vec::new(); + for members in shared_slots.into_values() { + let m = vars.add(variable().min(0)); + objective += m; + shared_slot_vars.push((m, members)); + } + for (c, choice) in input.choices.iter().enumerate() { + for slot in &choice.coefficients.slots { + if slot.shared_site.is_some() { + continue; + } + objective += slot.fixed_cost * x[c]; + if slot.cost_per_unit_rate != 0.0 + && !matches!(slot.materialization, Materialization::MatAtIngestion(_)) + { + let g = choice.group.0 as usize; + let slowest = slowest_rate(g); + objective += slowest * slot.cost_per_unit_rate * x[c]; + for (set, selector) in &rate_of[&g] { + let union = input.union_rate[set]; + if union > slowest { + rated.push((*selector, x[c], (union - slowest) * slot.cost_per_unit_rate)); + } + } + } + } + } + let mut products = Vec::new(); + for &(a, b, coefficient) in &rated { + let z = vars.add(variable().min(0).max(1)); + objective += coefficient * z; + products.push((z, a, b)); + } + // Lower bounds on the omitted terms from earlier cuts. + let extra: Vec = input + .extra_bounds + .iter() + .map(|_| vars.add(variable().min(0))) + .collect(); + for &e in &extra { + objective += e; + } + + // Query-time reach and latency (for held memory and latency bounds): + // `at_query_time[q, g]`, query `q` reaches group `g` without passing an + // ingestion-time slot; `work_reached[q, c]`, it reaches choice `c` so. + let mut at_query_time: HashMap<(usize, usize), Variable> = HashMap::new(); + for (g, queries) in input.group_queries.iter().enumerate() { + for &q in queries { + at_query_time.insert((q, g), vars.add(variable().min(0).max(1))); + } + } + // A choice's query-time work, without its nodes another group's + // choice may share (shareable nodes and shared slots): Stage 3 counts a + // shared node once per query, so they are left out to keep latency a + // lower bound. + let query_time_work = |c: usize| -> f64 { + let coefficients = &input.choices[c].coefficients; + let shared: f64 = coefficients.shared_nodes.iter().map(|n| n.work_ms).sum(); + (coefficients.direct.work_ms - shared).max(0.0) + + coefficients + .slots + .iter() + .filter(|s| { + !matches!(s.materialization, Materialization::MatAtIngestion(_)) + && s.shared_site.is_none() + }) + .map(|s| s.work_ms) + .sum::() + }; + let held_of = |c: usize| input.held.get(c).copied().unwrap_or_default(); + let mut work_reached: HashMap<(usize, usize), Variable> = HashMap::new(); + for (c, choice) in input.choices.iter().enumerate() { + let held = held_of(c); + if query_time_work(c) <= 0.0 && held.per_reader_per_ms <= 0.0 && held.longest_per_ms <= 0.0 + { + continue; + } + for &q in &input.group_queries[choice.group.0 as usize] { + work_reached.insert((q, c), vars.add(variable().min(0).max(1))); + } + } + // Held memory per (choice, query) for a reader's own copy, and per + // choice for one copy over the longest reader. + let mut held_per_reader: HashMap<(usize, usize), Variable> = HashMap::new(); + let mut held_longest: HashMap = HashMap::new(); + for &(q, c) in work_reached.keys() { + let held = held_of(c); + if held.per_reader_per_ms > 0.0 { + let h = vars.add(variable().min(0)); + objective += h; + held_per_reader.insert((q, c), h); + } + if held.longest_per_ms > 0.0 && !held_longest.contains_key(&c) { + let h = vars.add(variable().min(0)); + objective += h; + held_longest.insert(c, h); + } + } + let vars_exclusive = (vars.add(binary()), vars.add(binary())); + let source_count = input + .raw_scans + .iter() + .map(|s| s.source + 1) + .max() + .unwrap_or(0); + let kept_on_disk: Vec = (0..source_count) + .map(|_| vars.add(variable().min(0))) + .collect(); + for &v in &kept_on_disk { + objective += v; + } + let variable_count = vars.len() as u64; + let model = vars.minimise(objective.clone()).using(highs); + let mut constraints = Vec::new(); + // Needed groups: every query's roots; children of chosen choices. + for query in &input.queries { + for root in &query.roots { + constraints.push(constraint!(need[root.0 as usize] == 1)); + } + } + for (g, &needed) in need.iter().enumerate() { + // One choice implementing it (its own, or a fused one covering + // it) when needed, none otherwise. + let mut implementing = Expression::from(0.0); + for (c, choice) in input.choices.iter().enumerate() { + if choice.implements.iter().any(|i| i.0 as usize == g) { + implementing += x[c]; + } + } + constraints.push(constraint!(implementing == needed)); + } + for (c, choice) in input.choices.iter().enumerate() { + for child in &choice.children { + constraints.push(constraint!(need[child.0 as usize] >= x[c])); + } + } + for (&g, &v) in &once { + constraints.push(constraint!(v <= need[g.0 as usize])); + let slot = input.group_slots.iter().find(|s| s.group == g).unwrap(); + if !slot.may_recompute { + constraints.push(constraint!(v == need[g.0 as usize])); + } + } + // Reach at all: a query reaches its roots, and every child of a chosen + // choice of a group it reaches. + for (q, query) in input.queries.iter().enumerate() { + for root in &query.roots { + if let Some(&v) = reaches_at_all.get(&(q, root.0 as usize)) { + constraints.push(constraint!(v == 1)); + } + } + } + // Aggregated over a group's choices (one is chosen when it is + // needed), which keeps the LP relaxation tight. + let mut choices_of: Vec> = vec![Vec::new(); n]; + for (c, choice) in input.choices.iter().enumerate() { + choices_of[choice.group.0 as usize].push(c); + } + for (g, of_g) in choices_of.iter().enumerate() { + let mut children: BTreeSet = BTreeSet::new(); + for &c in of_g { + children.extend(input.choices[c].children.iter().map(|h| h.0 as usize)); + } + for h in children { + let mut with_child = Expression::from(0.0); + for &c in of_g { + if input.choices[c].children.iter().any(|k| k.0 as usize == h) { + with_child += x[c]; + } + } + for &q in &input.group_queries[g] { + if let (Some(&above), Some(&below)) = + (reaches_at_all.get(&(q, g)), reaches_at_all.get(&(q, h))) + { + constraints.push(constraint!(below >= above + with_child.clone() - 1)); + } + } + } + } + // Rate selectors: exactly the classes of the queries reaching the + // group (at most one selector; a class is in it iff one of its + // queries reaches the group). + for (&g, sets) in &rate_of { + let mut any = Expression::from(0.0); + for (_, selector) in sets { + any += *selector; + } + constraints.push(constraint!(any <= 1)); + for class in classes_of(g) { + let mut containing = Expression::from(0.0); + for (set, selector) in sets { + if set.contains(&class) { + containing += *selector; + } + } + for &q in &input.group_queries[g] { + if input.schedule[q] == class { + constraints.push(constraint!(containing.clone() >= reaches_at_all[&(q, g)])); + } + } + let mut members = Expression::from(0.0); + for &q in &input.group_queries[g] { + if input.schedule[q] == class { + members += reaches_at_all[&(q, g)]; + } + } + constraints.push(constraint!(containing <= members)); + } + } + // Consumer reach. + for (q, query) in input.queries.iter().enumerate() { + for root in &query.roots { + if let Some(&v) = reach.get(&(Consumer::Query(q), root.0 as usize)) { + constraints.push(constraint!(v >= need[root.0 as usize])); + } + } + } + for (&(k, g), &p) in &passes { + match k { + Consumer::Group(own) if own.0 as usize == g => { + constraints.push(constraint!(p >= once[&own])); + } + _ => { + let r = reach[&(k, g)]; + match once.get(&GroupId(g as u32)) { + Some(&o) => constraints.push(constraint!(p >= r - o)), + None => constraints.push(constraint!(p >= r)), + } + } + } + } + for (&(k, c), &v) in &y { + let g = input.choices[c].group.0 as usize; + constraints.push(constraint!(v >= passes[&(k, g)] + x[c] - 1)); + } + for (&(k, g), &p) in &passes { + let mut into = Expression::from(0.0); + for &c in &choices_of[g] { + into += y[&(k, c)]; + } + // Unless a fused choice of another group implements it. + for (c, choice) in input.choices.iter().enumerate() { + if choice.group.0 as usize != g && choice.implements.iter().any(|i| i.0 as usize == g) { + into += x[c]; + } + } + constraints.push(constraint!(into >= p)); + } + // A slot's consumer exists only when its choice is chosen, and a + // group's own consumer only when the group is built once. + let exists = |k: Consumer| -> Option { + match k { + Consumer::Slot { choice, .. } => Some(x[choice]), + Consumer::Group(g) => once.get(&g).copied(), + Consumer::Query(_) => None, + } + }; + for (&(k, _), &r) in &reach { + if let Some(e) = exists(k) { + constraints.push(constraint!(r <= e)); + } + } + for (&(k, _), &p) in &passes { + if let Some(e) = exists(k) { + constraints.push(constraint!(p <= e)); + } + } + for (&(k, _), &v) in &y { + if let Some(e) = exists(k) { + constraints.push(constraint!(v <= e)); + } + } + for (c, choice) in input.choices.iter().enumerate() { + for (child, via) in choice.children.iter().zip(&choice.coefficients.child_via) { + let h = child.0 as usize; + match via { + Via::Direct => { + // Aggregated below, over the group's choices. + let _ = h; + } + Via::Slot(slot) => { + let k = Consumer::Slot { + choice: c, + slot: *slot, + }; + if let Some(&r) = reach.get(&(k, h)) { + constraints.push(constraint!(r >= x[c])); + } + } + } + } + } + for (g, of_g) in choices_of.iter().enumerate() { + let mut children: BTreeSet = BTreeSet::new(); + for &c in of_g { + for (child, via) in input.choices[c] + .children + .iter() + .zip(&input.choices[c].coefficients.child_via) + { + if *via == Via::Direct { + children.insert(child.0 as usize); + } + } + } + for h in children { + for &(k, pg) in passes.keys() { + if pg != g { + continue; + } + let Some(&r) = reach.get(&(k, h)) else { + continue; + }; + let mut into = Expression::from(0.0); + for &c in of_g { + let choice = &input.choices[c]; + if choice + .children + .iter() + .zip(&choice.coefficients.child_via) + .any(|(child, via)| child.0 as usize == h && *via == Via::Direct) + { + into += y[&(k, c)]; + } + } + constraints.push(constraint!(r >= into)); + } + } + } + for &(z, a, b) in products.iter().chain(&products_pending) { + constraints.push(constraint!(z >= a + b - 1)); + } + for &(a, b, u, c, big_m) in &cheaper_of { + let mut per_consumer = Expression::from(0.0); + for &(v, w) in &per_consumer_shared[c] { + per_consumer += w * v; + } + let mut built_once = Expression::from(0.0); + for &(v, w) in &built_once_shared[c] { + built_once += w * v; + } + // Both are at least the slowest class's rate (some query reaches + // the chosen choice): charged linearly, which keeps the relaxation + // tight; only the excess goes through the binary choice. + let floor = shared_cost[c] * slowest_rate(input.choices[c].group.0 as usize); + constraints.push(constraint!(a >= per_consumer - floor * x[c] - big_m * u)); + constraints.push(constraint!( + b >= built_once - floor * x[c] - big_m * (1 - u) + )); + } + // Query-time reach: a query reaches its roots, and through a chosen + // choice its children read directly or by a query-time slot. + for (q, query) in input.queries.iter().enumerate() { + for root in &query.roots { + if let Some(&v) = at_query_time.get(&(q, root.0 as usize)) { + constraints.push(constraint!(v == 1)); + } + } + } + for (g, of_g) in choices_of.iter().enumerate() { + let mut children: BTreeSet = BTreeSet::new(); + for &c in of_g { + children.extend(input.choices[c].children.iter().map(|h| h.0 as usize)); + } + for h in children { + let mut with_child = Expression::from(0.0); + for &c in of_g { + let choice = &input.choices[c]; + let at_query = choice + .children + .iter() + .zip(&choice.coefficients.child_via) + .any(|(child, via)| { + child.0 as usize == h + && match via { + Via::Direct => true, + Via::Slot(i) => !matches!( + choice.coefficients.slots[*i].materialization, + Materialization::MatAtIngestion(_) + ), + } + }); + if at_query { + with_child += x[c]; + } + } + for &q in &input.group_queries[g] { + if let (Some(&above), Some(&below)) = + (at_query_time.get(&(q, g)), at_query_time.get(&(q, h))) + { + constraints.push(constraint!(below >= above + with_child.clone() - 1)); + } + } + } + } + for (&(q, c), &w) in &work_reached { + let g = input.choices[c].group.0 as usize; + constraints.push(constraint!(w >= at_query_time[&(q, g)] + x[c] - 1)); + } + // Each query's latency: the query-time work of the choices it reaches + // (a lower bound: compaction is left out). + let latency_of = |q: usize| { + let mut latency = Expression::from(0.0); + for (&(r, c), &w) in &work_reached { + if r == q { + latency += query_time_work(c) * w; + } + } + latency + }; + for (q, query) in input.queries.iter().enumerate() { + if let Some(bound) = query.max_latency_ms { + constraints.push(constraint!(latency_of(q) <= bound)); + } + } + // Held memory, linearized: h ≥ coefficient · (latency − L · (1 − w)), + // L the longest the query's latency can be; exact when w is 0 or 1. + let longest_latency: Vec = (0..input.queries.len()) + .map(|q| { + choices_of + .iter() + .enumerate() + .filter(|(g, _)| input.group_queries[*g].contains(&q)) + .map(|(_, of_g)| of_g.iter().map(|&c| query_time_work(c)).fold(0.0, f64::max)) + .sum() + }) + .collect(); + // A part read by several queries runs at their classes' union rate, + // not their rates' sum: per reader, its held memory is charged at + // `union / sum` of that reader's rate (Stage 3's rate-weighted mean + // latency at the part's rate). The least ratio over the group's class + // sets bounds it from below. + let per_reader_share: Vec = input + .group_queries + .iter() + .map(|queries_of| { + input + .union_rate + .iter() + .filter_map(|(set, &union)| { + let rate_sum: f64 = queries_of + .iter() + .filter(|&&q| set.contains(&input.schedule[q])) + .map(|&q| input.queries[q].rate) + .sum(); + let covered = set + .iter() + .all(|class| queries_of.iter().any(|&q| input.schedule[q] == *class)); + (covered && rate_sum > 0.0).then_some((union / rate_sum).min(1.0)) + }) + .fold(1.0, f64::min) + }) + .collect(); + for (&(q, c), &w) in &work_reached { + let held = held_of(c); + let longest = longest_latency[q]; + if let Some(&h) = held_per_reader.get(&(q, c)) { + let share = per_reader_share[input.choices[c].group.0 as usize]; + let coefficient = held.per_reader_per_ms * input.queries[q].rate * share; + constraints.push(constraint!( + h >= coefficient * (latency_of(q) - longest * (1 - w)) + )); + } + if let Some(&h) = held_longest.get(&c) { + let g = input.choices[c].group.0 as usize; + let rate = input.group_queries[g] + .iter() + .map(|&r| input.queries[r].rate) + .reduce(f64::min) + .unwrap_or(0.0); + let coefficient = held.longest_per_ms * rate; + constraints.push(constraint!( + h >= coefficient * (latency_of(q) - longest * (1 - w)) + )); + } + } + for (m, charges) in scan_maxima { + for charge in charges { + constraints.push(constraint!(m >= charge)); + } + } + for (once, charged, u, members, per_consumer, big_m) in cross_vars { + for (c, charge) in members { + constraints.push(constraint!(once >= charge * x[c])); + } + constraints.push(constraint!(charged >= per_consumer - big_m * u)); + constraints.push(constraint!(charged >= once - big_m * (1 - u))); + } + for (m, members) in &shared_slot_vars { + for &(c, charge) in members { + constraints.push(constraint!(*m >= charge * x[c])); + } + } + // Cuts. + for (set, outside, bound) in &input.excluded { + let mut chosen = Expression::from(0.0); + for &c in set { + chosen += x[c]; + } + for &g in outside { + chosen -= need[g]; + } + constraints.push(constraint!(chosen <= *bound)); + } + for ((set, bound), &e) in input.extra_bounds.iter().zip(&extra) { + let mut chosen = Expression::from(0.0); + for &c in set { + chosen += x[c]; + } + constraints.push(constraint!( + e >= *bound * (chosen - (set.len() as f64 - 1.0)) + )); + } + for (c, allowed) in &input.requires { + let mut any = Expression::from(0.0); + for &a in allowed { + any += x[a]; + } + constraints.push(constraint!(x[*c] <= any)); + } + for (all_of, one_of) in &input.rollup_rules { + let mut chosen = Expression::from(0.0); + for set in all_of { + for &c in set { + chosen += x[c]; + } + } + for &c in one_of { + chosen -= x[c]; + } + constraints.push(constraint!(chosen <= all_of.len() as f64 - 1.0)); + } + let (first, second) = &input.exclusive; + if !first.is_empty() && !second.is_empty() { + let (p, t) = (vars_exclusive.0, vars_exclusive.1); + for &c in first { + constraints.push(constraint!(x[c] <= p)); + } + for &c in second { + constraints.push(constraint!(x[c] <= t)); + } + constraints.push(constraint!(p + t <= 1)); + } + // Raw data: each source's longest query-time scan is kept on disk; a + // deployment without raw data runs no raw scan at query time. A part + // runs at query time for every consumer but an ingestion-time slot. + let at_query_time = |k: Consumer| match k { + Consumer::Slot { choice, slot } => !matches!( + input.choices[choice].coefficients.slots[slot].materialization, + Materialization::MatAtIngestion(_) + ), + _ => true, + }; + for scan in &input.raw_scans { + let c = scan.choice; + let mut runs: Vec = Vec::new(); + match scan.via { + Via::Direct => { + for (&(k, d), &v) in &y { + if d == c && at_query_time(k) { + runs.push(v); + } + } + } + Via::Slot(i) => { + let m = input.choices[c].coefficients.slots[i].materialization; + if !matches!(m, Materialization::MatAtIngestion(_)) { + runs.push(x[c]); + } + } + } + for v in runs { + if !input.raw_data_retained { + constraints.push(constraint!(v == 0)); + } else if scan.cost > 0.0 { + constraints.push(constraint!(kept_on_disk[scan.source] >= scan.cost * v)); + } + } + } + let constraint_count = constraints.len() as u64; + let mut model = model + .with_all(constraints) + .with_mip_gap(0.0) + .map_err(|e| format!("MIP gap: {e:?}"))?; + // No absolute gap either: prices are scaled to about 1. + model = model.set_option("mip_abs_gap", 0.0); + if let Some(limit) = input.time_limit_s { + model = model.set_time_limit(limit.max(0.001)); + } + let solution = match model.solve() { + Ok(solution) => solution, + Err(good_lp::ResolutionError::Infeasible) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let chosen: Vec = x + .iter() + .enumerate() + .filter(|(_, v)| solution.value(**v) > 0.5) + .map(|(c, _)| c) + .collect(); + let built_once = once + .iter() + .filter(|(_, v)| solution.value(**v) > 0.5) + .map(|(g, _)| *g) + .collect(); + Ok(Some(ModelSolution { + chosen, + built_once, + // Stopped within the solver's own tolerance of a zero gap, the + // objective is lowered by more than that tolerance to stay a bound. + objective: match solution.status() { + good_lp::solvers::SolutionStatus::GapLimit => { + let value = solution.eval(&objective); + value - value.abs() * GAP_TOLERANCE + } + _ => solution.eval(&objective), + }, + optimal: matches!( + solution.status(), + good_lp::solvers::SolutionStatus::Optimal | good_lp::solvers::SolutionStatus::GapLimit + ), + variables: variable_count, + constraints: constraint_count, + })) +} + +/// For each group, the consumers that may reach its node: its queries +/// (that reach it without passing a materialized node), the materialized +/// private slots of choices of groups above it, and the shared groups +/// above it (or itself) that may be built once. +fn possible_consumers(input: &ModelInput) -> BTreeMap> { + let mut consumers: BTreeMap> = BTreeMap::new(); + for (g, queries) in input.group_queries.iter().enumerate() { + let entry = consumers.entry(g).or_default(); + for &q in queries { + entry.insert(Consumer::Query(q)); + } + } + // Propagate downwards until nothing changes. + let shared: BTreeSet = input + .group_slots + .iter() + .map(|s| s.group.0 as usize) + .collect(); + loop { + let mut changed = false; + for (c, choice) in input.choices.iter().enumerate() { + let g = choice.group.0 as usize; + let mut above: BTreeSet = consumers.get(&g).cloned().unwrap_or_default(); + if shared.contains(&g) { + above.insert(Consumer::Group(GroupId(g as u32))); + } + for (child, via) in choice.children.iter().zip(&choice.coefficients.child_via) { + let entry = consumers.entry(child.0 as usize).or_default(); + let before = entry.len(); + match via { + Via::Direct => entry.extend(above.iter().copied()), + Via::Slot(slot) => { + entry.insert(Consumer::Slot { + choice: c, + slot: *slot, + }); + } + } + changed |= entry.len() != before; + } + } + if !changed { + break; + } + } + consumers +} + +/// A memo search's result: the selection, its physical candidate and its +/// logical roots, or why no plan is valid. +pub(crate) type MemoSearchResult = + Result<(Selection, PhysicalCandidate, Vec), SelectionError>; + +/// The cheapest valid plan of `per_computation`'s memo under cost model 1 +/// (the caller routes cost models 2 and 3 to the legacy search). +/// +/// Lazy loop: solve the model (a lower bound: it omits memory held while a +/// query runs, and checks composed guarantees only on its incumbents); +/// price the solution's group choice exactly, as the reference search +/// prices one ([`offer_group_choice`]: every threshold, identical-expression +/// variant and Stage 2 materialization), which updates the incumbent; add +/// a no-good cut excluding that group choice; stop when the model's bound +/// reaches the incumbent. Each group choice is priced at most once, so the +/// loop ends, and the incumbent is the reference search's optimum. +pub(crate) fn search( + per_computation: &PerComputationCandidates, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, + time_limit_ms: Option, +) -> MemoSearchResult { + let started = std::time::Instant::now(); + let memo = super::search::memo(per_computation, entries); + let memo_stats = super::search::stats(&memo, started.elapsed().as_secs_f64() * 1e3); + let mut input = model_input_of(&memo, entries, data, models)?; + let mut selector = PlanSelector::new(entries, data, models, false)?; + let mut logical: HashMap> = HashMap::new(); + let mut number = 0usize; + let mut iterations = 0u32; + let mut bound = None; + let mut first_bound = None; + let mut solver_ms = 0.0; + let mut size = (0, 0); + let mut time_limit_reached = false; + loop { + if time_limit_reached { + break; + } + let remaining_s = + time_limit_ms.map(|limit| limit as f64 / 1e3 - started.elapsed().as_secs_f64()); + if remaining_s.is_some_and(|s| s <= 0.0) { + time_limit_reached = true; + break; + } + input.time_limit_s = remaining_s; + iterations += 1; + let solve_started = std::time::Instant::now(); + let solved = match solve(&input) { + Ok(solved) => solved, + // The solver stopped at the time limit before finding any + // solution: the incumbent so far stands. + Err(error) if remaining_s.is_some() && error.contains("NoSolutionFound") => { + time_limit_reached = true; + solver_ms += solve_started.elapsed().as_secs_f64() * 1e3; + break; + } + Err(error) => return Err(SelectionError::Solver(error)), + }; + solver_ms += solve_started.elapsed().as_secs_f64() * 1e3; + // The solver stopped at the time limit: its plan is no bound, but + // it is still priced, as a last round, and may become the + // incumbent. + let last_round = solved.as_ref().is_some_and(|s| !s.optimal); + time_limit_reached |= last_round; + let Some(solution) = solved else { + // No plan left below the incumbent: it is optimal. + if let Some(incumbent) = selector.cheapest_price() { + bound = Some(incumbent); + } + break; + }; + if !last_round { + bound = Some(solution.objective); + first_bound.get_or_insert(solution.objective); + } + size = (solution.variables, solution.constraints); + if let Some(incumbent) = selector.cheapest_price() { + if !last_round && solution.objective >= incumbent - 1e-9 * incumbent.abs() { + break; + } + } + // The solution's group choice: the candidate each chosen choice + // takes for its group. + let mut group_choice: super::search::GroupChoice = vec![None; memo.groups.len()]; + for &c in &solution.chosen { + group_choice[input.choices[c].group.0 as usize] = Some(input.choices[c].candidate); + } + // A group choice that fails a requirement the model does not + // encode is offered as any other: the reference evaluation finds + // no valid plan in it, and the cut below removes exactly it. + super::search::offer_group_choice( + &memo, + per_computation, + &group_choice, + entries, + data, + models, + &mut selector, + &mut logical, + &mut number, + )?; + // Exclude it: every choice of each chosen (group, candidate). + let groups = group_choice.iter().flatten().count(); + let excluded: Vec = input + .choices + .iter() + .enumerate() + .filter(|(_, choice)| group_choice[choice.group.0 as usize] == Some(choice.candidate)) + .map(|(c, _)| c) + .collect(); + let mut covered = vec![false; memo.groups.len()]; + for (g, c) in group_choice.iter().enumerate() { + if let Some(c) = c { + for i in &memo.candidates[g][*c].implements { + covered[i.0 as usize] = true; + } + } + } + let outside = (0..memo.groups.len()).filter(|&g| !covered[g]).collect(); + input + .excluded + .push((excluded, outside, groups as f64 - 1.0)); + } + if time_limit_reached && bound.is_none() { + // No round solved to optimality: the relaxation's optimum (all cuts + // kept) is still a lower bound for the gap. + input.relaxed = true; + input.time_limit_s = None; + // A failed relaxation leaves the gap unknown, never the incumbent lost. + if let Ok(Some(relaxation)) = solve(&input) { + bound = Some(relaxation.objective); + } + } + if !time_limit_reached && iterations == 1 && selector.cheapest_price().is_none() { + // Infeasible from the first round: name the queries with no plan + // of their own. + let mut without_plan = Vec::new(); + for (q, entry) in entries.iter().enumerate().take(input.queries.len()) { + let mut alone = model_input_clone(&input); + for (r, query) in alone.queries.iter_mut().enumerate() { + if r != q { + query.roots.clear(); + } + } + if solve(&alone).map_err(SelectionError::Solver)?.is_none() { + without_plan.push(format!("query {q} ({})", entry.query.0)); + } + } + if !without_plan.is_empty() { + return Err(SelectionError::Solver(format!( + "no valid plan for {} (raw data {})", + without_plan.join(", "), + match models.deployment_capabilities.raw_data_retained { + true => "retained", + false => "not retained", + } + ))); + } + } + if time_limit_reached && selector.cheapest_price().is_none() { + return Err(SelectionError::Solver(format!( + "the MILP search reached its time limit after {iterations} rounds before finding a valid plan" + ))); + } + let (mut selection, selected) = selector.finish()?; + let price = selection.costs[&selection.selected].cost_per_second; + selection.search.memo = Some(memo_stats); + // Once no plan below the incumbent is left, the bound is the incumbent. + let lower_bound = bound.map(|bound| match price { + Some(price) => bound.min(price), + None => bound, + }); + selection.search.incumbent_cost = price; + selection.search.milp = Some(crate::MilpReport { + lower_bound, + first_lower_bound: first_bound, + gap: match (price, lower_bound) { + (Some(price), Some(bound)) if price > 0.0 => Some(((price - bound) / price).max(0.0)), + (Some(_), Some(_)) => Some(0.0), + _ => None, + }, + rounds: iterations, + variable_count: size.0, + constraint_count: size.1, + solver_time_ms: solver_ms as u64, + time_limit_reached, + }); + let roots = logical + .remove(&selected.from_logical) + .expect("the selection's logical roots are kept"); + Ok((selection, selected, roots)) +} + +/// The model's input for `memo`: each group's shared choices (copies and +/// widened scans are left to the exact pricing) with their lazy lower +/// bounds, and the raw scans. +pub(super) fn model_input_of( + memo: &Memo, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, +) -> Result { + let scale = super::pricing::frozen_calibration(memo, entries, data, models)?; + let mut cache = super::pricing::PriceCache::new(scale); + let mut choices: Vec = Vec::new(); + let mut raw_scans: Vec<(usize, String, u64, Via)> = Vec::new(); + let mut lazies: Vec = Vec::new(); + let CostModel::UsageOverTime(prices) = &models.plan_selection.cost_model else { + unreachable!("the caller routes cost models 2 and 3 to the legacy search"); + }; + for g in 0..memo.groups.len() { + let (group_choices, lazy) = super::coefficients::choices( + memo, + GroupId(g as u32), + entries, + data, + models, + &mut cache, + )?; + // A group's copies (apart) cost at least its shared choice with its + // node not built once: each copy is charged per consumer as the + // shared node is, its own scans read at least the shortest horizon, + // and its builds are sized for fewer readers but built per copy. So + // the model keeps the shared choices (a lower bound), and the exact + // pricing of a group choice prices both. + // Widened scans are reached through the identical-expression + // variants of the exact pricing, never chosen directly. + for (choice, lazy) in group_choices.into_iter().zip(lazy) { + if choice.copy_of.is_some() + || memo.candidates[g][choice.candidate].origin + == super::CandidateOrigin::WidenedScans + { + continue; + } + lazies.push(lazy.clone()); + raw_scans.extend( + lazy.raw_scans + .iter() + .map(|(source, bytes, via)| (choices.len(), source.clone(), *bytes, *via)), + ); + choices.push(choice); + } + } + let mut input = model_input(memo, choices, entries, models)?; + let mut sources: Vec = Vec::new(); + for (c, source, bytes, via) in raw_scans { + let index = match sources.iter().position(|s| *s == source) { + Some(i) => i, + None => { + sources.push(source); + sources.len() - 1 + } + }; + input.raw_scans.push(RawScan { + choice: c, + source: index, + cost: prices.cost_per_disk_byte_second * bytes as f64, + via, + }); + } + input.raw_data_retained = models.deployment_capabilities.raw_data_retained; + // Nodes several groups' choices have, equal up to scan widening: one + // node once shared, charged once per key at the dearest chosen one + // (`ChoiceCoefficients::cross_shared`). + let mut groups_of_node: HashMap> = HashMap::new(); + for choice in &input.choices { + for &(key, _) in &choice.coefficients.direct_nodes { + groups_of_node.entry(key).or_default().insert(choice.group); + } + } + for choice in &mut input.choices { + let k = &mut choice.coefficients; + for &(_, cost, _) in &k.absorbed_scans { + k.direct.cost_per_unit_rate -= cost; + } + let cross: Vec<(u64, f64)> = k + .direct_nodes + .iter() + .copied() + .filter(|(key, _)| groups_of_node[key].len() > 1) + .collect(); + for &(key, cost) in &cross { + k.direct.cost_per_unit_rate -= cost; + k.cross_shared.push((key, cost)); + for node in &mut k.shared_nodes { + if node.members.contains(&key) { + node.cost_per_unit_rate -= cost; + } + } + } + k.shared_nodes.retain(|node| node.cost_per_unit_rate > 0.0); + k.direct.cost_per_unit_rate = k.direct.cost_per_unit_rate.max(0.0); + } + // A slot whose build several groups' choices may share is one node + // charged once (`SlotCoefficients::shared_site`): its held memory is + // left out rather than charged per choice. + let mut groups_of_key: HashMap<(u64, usize), BTreeSet> = HashMap::new(); + for choice in &input.choices { + for slot in &choice.coefficients.slots { + if let Some(key) = slot.shared_site { + groups_of_key.entry(key).or_default().insert(choice.group); + } + } + } + let shared_keys: BTreeSet<(u64, usize)> = groups_of_key + .into_iter() + .filter(|(_, groups)| groups.len() > 1) + .map(|(key, _)| key) + .collect(); + input.held = input + .choices + .iter() + .zip(&lazies) + .map(|(choice, lazy)| held_at_query_time(choice, lazy, prices, &shared_keys)) + .collect(); + Ok(input) +} + +/// Every group choice of `memo` with its exact price (the cheapest plan +/// [`super::search::offer_group_choice`] finds) and the model's value +/// restricted to it, which should be a lower bound of the price; group +/// choices without a valid plan are left out. +pub(crate) fn lower_bound_check( + memo: &Memo, + per_computation: &PerComputationCandidates, + input: &ModelInput, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, +) -> Result)>, SelectionError> { + let mut out = Vec::new(); + for group_choice in super::search::group_choices(memo).unwrap_or_default() { + let mut one = PlanSelector::new(entries, data, models, false)?; + let mut scratch = HashMap::new(); + let mut k = 0usize; + super::search::offer_group_choice( + memo, + per_computation, + &group_choice, + entries, + data, + models, + &mut one, + &mut scratch, + &mut k, + )?; + let Some(exact) = one.cheapest_price() else { + continue; + }; + let mut restricted = model_input_clone(input); + restricted.excluded.clear(); + let outside: Vec = (0..restricted.choices.len()) + .filter(|&c| { + group_choice[restricted.choices[c].group.0 as usize] + != Some(restricted.choices[c].candidate) + }) + .collect(); + restricted.excluded.push((outside, Vec::new(), 0.0)); + let value = solve(&restricted) + .map_err(SelectionError::Solver)? + .map(|s| s.objective); + out.push((group_choice, exact, value)); + } + Ok(out) +} + +fn model_input_clone(input: &ModelInput) -> ModelInput { + ModelInput { + group_count: input.group_count, + group_queries: input.group_queries.clone(), + choices: input.choices.clone(), + group_slots: input.group_slots.clone(), + queries: input.queries.clone(), + schedule: input.schedule.clone(), + union_rate: input.union_rate.clone(), + excluded: input.excluded.clone(), + extra_bounds: input.extra_bounds.clone(), + requires: input.requires.clone(), + exclusive: input.exclusive.clone(), + raw_scans: input.raw_scans.clone(), + raw_data_retained: input.raw_data_retained, + held: input.held.clone(), + time_limit_s: input.time_limit_s, + relaxed: input.relaxed, + rollup_rules: input.rollup_rules.clone(), + } +} + +/// The model's input for `memo`'s `choices`. +fn model_input( + memo: &Memo, + choices: Vec, + entries: &[QueryWorkloadEntry], + models: PlanningModels<'_>, +) -> Result { + let settings = models.plan_selection; + let rate = |entries: &[&QueryWorkloadEntry]| { + crate::shared_evaluation_rate(entries, settings).map_err(|error| SelectionError::Cost { + candidate: "memo".to_string(), + node: Default::default(), + error, + }) + }; + // Schedule classes: queries with one recurrence and range evaluation. + let mut classes: Vec<&QueryWorkloadEntry> = Vec::new(); + let schedule: Vec = entries + .iter() + .map(|entry| { + match classes.iter().position(|c| { + c.recurrence == entry.recurrence && c.range_evaluation == entry.range_evaluation + }) { + Some(class) => class, + None => { + classes.push(entry); + classes.len() - 1 + } + } + }) + .collect(); + let mut union_rate = HashMap::new(); + let mut queries = Vec::with_capacity(entries.len()); + for (q, entry) in entries.iter().enumerate() { + let roots = memo + .groups + .iter() + .enumerate() + .filter(|(_, group)| group.roots_of.contains(&q)) + .map(|(g, _)| GroupId(g as u32)) + .collect(); + // A query's evaluation must fit its latency bound and its repeat + // interval. + let interval = entry.recurrence.fixed_interval_ms().map(|ms| ms as f64); + let max_latency_ms = match (entry.requirements.max_latency_ms, interval) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + queries.push(ModelQuery { + rate: rate(&[entry])?, + roots, + max_latency_ms, + }); + } + let group_queries: Vec> = memo.groups.iter().map(|g| g.queries.clone()).collect(); + for queries_of in &group_queries { + let mut present: Vec = queries_of.iter().map(|&q| schedule[q]).collect(); + present.sort_unstable(); + present.dedup(); + for mask in 1u64..(1 << present.len()) { + let set: Vec = (0..present.len()) + .filter(|i| mask >> i & 1 == 1) + .map(|i| present[i]) + .collect(); + if let std::collections::hash_map::Entry::Vacant(slot) = union_rate.entry(set) { + let members: Vec<&QueryWorkloadEntry> = + slot.key().iter().map(|&c| classes[c]).collect(); + slot.insert(rate(&members)?); + } + } + } + // Requirements the reference search checks on a group choice + // ([`super::windows::requirements_met`]); rollups below. + let candidate_of = |c: &Choice| &memo.candidates[c.group.0 as usize][c.candidate]; + let mut requires = Vec::new(); + for (c, choice) in choices.iter().enumerate() { + let candidate = candidate_of(choice); + for &g in &candidate.requires_exact_children { + let allowed = (0..choices.len()) + .filter(|&a| { + choices[a].group == g && super::windows::is_exact(candidate_of(&choices[a])) + }) + .collect(); + requires.push((c, allowed)); + } + for (g, site) in &candidate.requires_sites { + let allowed = (0..choices.len()) + .filter(|&a| { + choices[a].group == *g + && candidate_of(&choices[a]).coupling_sites.contains(site) + }) + .collect(); + requires.push((c, allowed)); + } + } + // A reader of a scan shared at horizon `h` reads the scan group's + // choice at that horizon, so every reader of one scan agrees on it. + for (c, choice) in choices.iter().enumerate() { + for &(scan, horizon) in &choice.scan_children { + let Some(h) = horizon else { continue }; + let allowed = (0..choices.len()) + .filter(|&a| choices[a].group == scan && choices[a].scan_horizon_ms == Some(h)) + .collect(); + requires.push((c, allowed)); + } + } + // Rollups: every rule over the candidates with a choice, each + // candidate standing for its choices (one per group is chosen). + let mut choices_of: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); + for (c, choice) in choices.iter().enumerate() { + choices_of + .entry((choice.group.0 as usize, choice.candidate)) + .or_default() + .push(c); + } + let present: BTreeSet<(usize, usize)> = choices_of.keys().copied().collect(); + let rollup_rules = super::windows::rollup_rules(memo, &present) + .into_iter() + .map(|rule| { + ( + rule.all_of.iter().map(|k| choices_of[k].clone()).collect(), + rule.one_of + .iter() + .flat_map(|k| choices_of[k].clone()) + .collect(), + ) + }) + .collect(); + let of_origin = |origin: super::CandidateOrigin| -> Vec { + (0..choices.len()) + .filter(|&c| candidate_of(&choices[c]).origin == origin) + .collect() + }; + let exclusive = ( + of_origin(super::CandidateOrigin::Panes), + of_origin(super::CandidateOrigin::TopKReuse), + ); + let group_slots = memo + .groups + .iter() + .enumerate() + .filter(|(_, group)| group.parents.len() > 1 || group.roots_of.len() > 1) + .map(|(g, _)| { + let allowed = &memo.candidates[g][0].materializations; + GroupSlot { + group: GroupId(g as u32), + may_recompute: allowed.is_empty() + || allowed.contains(&Materialization::NotMaterialized), + } + }) + .collect(); + Ok(ModelInput { + group_count: memo.groups.len(), + group_queries, + choices, + group_slots, + queries, + schedule, + union_rate, + excluded: Vec::new(), + extra_bounds: Vec::new(), + requires, + exclusive, + rollup_rules, + raw_scans: Vec::new(), + raw_data_retained: true, + held: Vec::new(), + time_limit_s: None, + relaxed: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use asap_types::ir::properties::AccuracyTarget; + + fn choice(group: u32, children: &[u32], cost: f64) -> Choice { + Choice { + group: GroupId(group), + candidate: 0, + implements: vec![GroupId(group)], + children: children.iter().map(|&c| GroupId(c)).collect(), + assignment: Vec::new(), + coefficients: ChoiceCoefficients { + direct: PartCoefficients { + cost_per_unit_rate: cost, + work_ms: 1.0, + }, + slots: Vec::new(), + child_via: children.iter().map(|_| Via::Direct).collect(), + shared_nodes: Vec::new(), + cross_shared: Vec::new(), + absorbed_scans: Vec::new(), + direct_nodes: Vec::new(), + }, + copy_of: None, + scan_children: Vec::new(), + scan_horizon_ms: None, + scan_source: None, + scan_project_cost_per_unit_rate: 0.0, + } + } + + /// A scan two queries read costs once per query recomputing it, or once + /// per evaluation when built once: the model builds it once when that + /// is cheaper, and its objective is the plan's price. + #[test] + fn a_shared_group_is_built_once_when_that_is_cheaper() { + let input = ModelInput { + group_count: 3, + group_queries: vec![vec![0, 1], vec![0], vec![1]], + choices: vec![ + choice(0, &[], 10.0), + choice(1, &[0], 1.0), + choice(2, &[0], 1.0), + ], + group_slots: vec![GroupSlot { + group: GroupId(0), + may_recompute: true, + }], + queries: vec![ + ModelQuery { + rate: 1.0, + roots: vec![GroupId(1)], + max_latency_ms: None, + }, + ModelQuery { + rate: 1.0, + roots: vec![GroupId(2)], + max_latency_ms: None, + }, + ], + schedule: vec![0, 0], + union_rate: [(vec![0], 1.0)].into(), + excluded: Vec::new(), + extra_bounds: Vec::new(), + requires: Vec::new(), + exclusive: (Vec::new(), Vec::new()), + rollup_rules: Vec::new(), + raw_scans: Vec::new(), + raw_data_retained: true, + held: Vec::new(), + time_limit_s: None, + relaxed: false, + }; + let solution = solve(&input).unwrap().unwrap(); + assert_eq!(solution.built_once, [GroupId(0)].into()); + assert!( + (solution.objective - 12.0).abs() < 1e-9, + "{}", + solution.objective + ); + // Recomputing is cheaper when the scan is cheap. + let mut cheap = input; + cheap.choices[0].coefficients.direct.cost_per_unit_rate = 0.1; + cheap.union_rate.insert(vec![0], 1.0); + // Built once still costs its union rate × 0.1 = 0.1 < 0.2. + let solution = solve(&cheap).unwrap().unwrap(); + assert!( + (solution.objective - 2.1).abs() < 1e-9, + "{}", + solution.objective + ); + } + + /// A group choice: per group, its candidate. + type Plan = Vec>; + + /// The plans of `queries`' memo (each query every minute, at `targets`) + /// the model may return, as group choices: the model of every choice + /// with no latency bound, solved with a no-good cut on each group + /// choice it returns until it is infeasible; and the group choices of + /// the same candidates the reference search accepts + /// ([`super::super::windows::requirements_met`]). + fn model_and_reference_plans( + queries: &[&str], + targets: &[AccuracyTarget], + ) -> (BTreeSet, BTreeSet) { + use asap_logical_asap_optimization::per_computation_candidates::generate_per_computation_candidates; + use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; + use asap_types::ir::schema::schema_support::with_promql_series_identity; + use asap_types::workload::{ + DataArrival, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, + SourceFacts, + }; + let roots: Vec<(usize, QueryRoot)> = queries + .iter() + .zip(targets) + .map(|(q, target)| { + let root = lower_promql(q, target.clone()); + QueryRoot::Operator(with_promql_series_identity(&root).unwrap()) + }) + .enumerate() + .collect(); + let per_computation = generate_per_computation_candidates(roots).unwrap(); + let entries: Vec = targets + .iter() + .map(|target| { + let mut entry = entry_with( + target.clone(), + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( + 60_000, + ))), + ); + entry.predictability = Predictability::Predictable { known_at: None }; + entry + }) + .collect(); + let data = DataWorkload { + arrival: DataArrival::Ingesting { + rows_per_second: 10_000.0, + }, + sources: [( + "m".to_string(), + SourceFacts { + series_count: Some(10_000), + ..SourceFacts::default() + }, + )] + .into_iter() + .collect(), + }; + let models = PlanningModels::builtin(); + let memo = super::super::search::memo(&per_computation, &entries); + let scale = + super::super::pricing::frozen_calibration(&memo, &entries, &data, models).unwrap(); + let mut cache = super::super::pricing::PriceCache::new(scale); + let mut choices = Vec::new(); + for g in 0..memo.groups.len() { + let (group_choices, _) = super::super::coefficients::choices( + &memo, + GroupId(g as u32), + &entries, + &data, + models, + &mut cache, + ) + .unwrap(); + choices.extend(group_choices.into_iter().filter(|choice| { + choice.copy_of.is_none() + && memo.candidates[g][choice.candidate].origin + != super::super::CandidateOrigin::WidenedScans + })); + } + let mut input = model_input(&memo, choices, &entries, models).unwrap(); + assert!( + input + .choices + .iter() + .any(|c| memo.candidates[c.group.0 as usize][c.candidate] + .rollup_of + .is_some()), + "a rollup is a choice" + ); + for query in &mut input.queries { + query.max_latency_ms = None; + } + let present: BTreeSet<(usize, usize)> = input + .choices + .iter() + .map(|c| (c.group.0 as usize, c.candidate)) + .collect(); + let mut model: BTreeSet>> = BTreeSet::new(); + while let Some(solution) = solve(&input).unwrap() { + let mut plan = vec![None; memo.groups.len()]; + for &c in &solution.chosen { + plan[input.choices[c].group.0 as usize] = Some(input.choices[c].candidate); + } + let excluded: Vec = (0..input.choices.len()) + .filter(|&c| { + plan[input.choices[c].group.0 as usize] == Some(input.choices[c].candidate) + }) + .collect(); + let groups = plan.iter().flatten().count(); + let mut covered = vec![false; memo.groups.len()]; + for (g, c) in plan.iter().enumerate() { + if let Some(c) = c { + for i in &memo.candidates[g][*c].implements { + covered[i.0 as usize] = true; + } + } + } + let outside = (0..memo.groups.len()).filter(|&g| !covered[g]).collect(); + input + .excluded + .push((excluded, outside, groups as f64 - 1.0)); + assert!(model.insert(plan), "a group choice returned twice"); + } + // Only groups with a choice to make: one with a single candidate + // is in a plan or not by how its readers reach it (a scan read + // apart is each reader's own copy), not by a choice. + let decided: Vec = (0..memo.groups.len()) + .map(|g| present.iter().filter(|(h, _)| *h == g).count() > 1) + .collect(); + let project = |plan: Vec>| -> Vec> { + plan.into_iter() + .zip(&decided) + .map(|(c, &decided)| c.filter(|_| decided)) + .collect() + }; + let model = model.into_iter().map(project).collect(); + let reference = super::super::search::group_choices(&memo) + .unwrap() + .into_iter() + .filter(|plan| { + plan.iter() + .enumerate() + .all(|(g, c)| c.is_none_or(|c| present.contains(&(g, c)))) + }) + .filter(|plan| { + let chosen: Vec<(GroupId, &super::super::ImplementationCandidate)> = plan + .iter() + .enumerate() + .filter_map(|(g, c)| Some((GroupId(g as u32), &memo.candidates[g][(*c)?]))) + .collect(); + super::super::windows::requirements_met(&chosen) + }) + .map(project) + .collect(); + (model, reference) + } + + /// Exact sums by `job`, `(job, instance)` and `(job, instance, zone)`: + /// a coarser sum may roll up from either finer one, but the joint rule + /// rolls it up from the coarsest finer build chosen, which must be exact. + /// The group choices the model may return are exactly those the + /// reference search accepts. + #[test] + fn the_model_admits_exactly_the_plans_whose_rollups_meet_the_joint_rule() { + let (model, reference) = model_and_reference_plans( + &[ + "sum by (job) (m)", + "sum by (job, instance) (m)", + "sum by (job, instance, zone) (m)", + ], + &[ + AccuracyTarget::exact(), + AccuracyTarget::exact(), + AccuracyTarget::exact(), + ], + ); + assert!(!reference.is_empty()); + let only_model: Vec<_> = model.difference(&reference).collect(); + let only_reference: Vec<_> = reference.difference(&model).collect(); + assert!( + only_model.is_empty() && only_reference.is_empty(), + "model only {only_model:?}, reference only {only_reference:?} ({} plans)", + reference.len() + ); + } +} diff --git a/crates/plan-selection/src/pass/subexpr/mod.rs b/crates/plan-selection/src/pass/subexpr/mod.rs index 54c8bf4b8..01b0e24af 100644 --- a/crates/plan-selection/src/pass/subexpr/mod.rs +++ b/crates/plan-selection/src/pass/subexpr/mod.rs @@ -31,18 +31,21 @@ //! [`scans::widened_at_threshold`]; [`scans::horizon_thresholds`] (no //! rewrite: Stage 3 prices the horizon). //! -//! [`search::brute_force`] is the reference search, run when -//! [`crate::pass::Stage1ToStage3Search::memo_groups`] is set: every +//! [`search::brute_force`] is the reference search, run for +//! [`crate::SearchAlgorithm::MemoBruteForce`]: every //! consistent choice of one candidate per needed group and one threshold //! per site its candidates read, composed into one plan (identical nodes //! shared, equal resized builds made one), implemented by Stage 2 in every -//! materialization and priced by Stage 3. [`pricing`] holds the per-node -//! local prices, plan-level charges and frontiers the decomposed search -//! will use in its place. +//! materialization and priced by Stage 3. [`coefficients`] turns Stage 3's per-node +//! prices into the coefficients of [`milp`], the memo solved as a +//! mixed-integer linear program ([`crate::SearchAlgorithm::MemoMilp`]). + #![allow(dead_code)] pub(crate) mod capability; +pub(crate) mod coefficients; pub(crate) mod groups; +pub(crate) mod milp; pub(crate) mod pricing; pub(crate) mod scans; pub(crate) mod search; diff --git a/crates/plan-selection/src/pass/subexpr/pricing.rs b/crates/plan-selection/src/pass/subexpr/pricing.rs index 9ceb6180f..e682dd714 100644 --- a/crates/plan-selection/src/pass/subexpr/pricing.rs +++ b/crates/plan-selection/src/pass/subexpr/pricing.rs @@ -137,6 +137,9 @@ pub(crate) struct PriceCache { /// Local prices by (candidate's structural key, consumers, /// materialization, children's outputs). prices: HashMap>, + /// The raw windows of each scan horizon key of the memo + /// (`coefficients::raw_windows`), computed once per memo. + pub(super) raw_windows: Option>>>, /// Local prices found in the cache. pub hits: u64, /// Local prices computed. @@ -151,6 +154,11 @@ impl PriceCache { ..Self::default() } } + + /// The interned structural node keys. + pub(super) fn keys_mut(&mut self) -> &mut HashMap { + &mut self.keys + } } /// A candidate's sub-DAG as Stage 2 builds it, with its nodes' keys. @@ -162,9 +170,9 @@ struct Part { /// A child group's sub-DAG's node keys, and its root's. #[derive(Debug, Default)] -struct ChildKeys { - nodes: HashSet, - root: Option, +pub(super) struct ChildKeys { + pub(super) nodes: HashSet, + pub(super) root: Option, } /// The usage of `candidate`'s private nodes (from its node down to, @@ -427,7 +435,7 @@ fn built( /// The node keys of `child`'s own sub-DAG (one reader, nothing /// materialized) and its root's key, cached by the child's node. -fn child_keys( +pub(super) fn child_keys( memo: &Memo, child: GroupId, entries: &[QueryWorkloadEntry], @@ -470,7 +478,7 @@ fn child_keys( /// producers' keys, and its output schema; placement (timing, /// materialization) left out, so a child's nodes match whatever is chosen /// for them. -fn keys_of(dag: &PhysicalASAPDAG, interned: &mut HashMap) -> Vec { +pub(super) fn keys_of(dag: &PhysicalASAPDAG, interned: &mut HashMap) -> Vec { let mut key_of: HashMap = HashMap::new(); dag.nodes .iter() @@ -632,7 +640,7 @@ fn price_part( /// The raw data a query-time scan group priced on its own reads: the /// longest window its parent groups' nodes put on it (through time /// shifts); `None` when it is not a scan or no window bounds it. -fn scan_lookback_ms(memo: &Memo, group: GroupId) -> Option { +pub(super) fn scan_lookback_ms(memo: &Memo, group: GroupId) -> Option { let scan = &memo.groups[group.0 as usize]; if !matches!(scan.node.non_asap(), Some(NonASAPOp::Scan { .. })) { return None; diff --git a/crates/plan-selection/src/pass/subexpr/scans.rs b/crates/plan-selection/src/pass/subexpr/scans.rs index ce36fd191..c472b05ac 100644 --- a/crates/plan-selection/src/pass/subexpr/scans.rs +++ b/crates/plan-selection/src/pass/subexpr/scans.rs @@ -416,7 +416,7 @@ fn raw_reader(node: &Rc) -> Option { return None; }; Some(RawReader { - key: hash_of(&("RawHorizon", format!("{source:?}"))), + key: raw_horizon_key(source), horizon_ms: u64::try_from(range.as_millis()).unwrap_or(u64::MAX), }) } @@ -480,6 +480,11 @@ fn child_groups(boundary: &mut Boundary, node: &Rc) -> Vec u64 { + hash_of(&("RawHorizon", format!("{source:?}"))) +} + fn hash_of(value: &T) -> u64 { let mut hasher = DefaultHasher::new(); value.hash(&mut hasher); diff --git a/crates/plan-selection/src/pass/subexpr/search.rs b/crates/plan-selection/src/pass/subexpr/search.rs index a02d199b9..7de508e4f 100644 --- a/crates/plan-selection/src/pass/subexpr/search.rs +++ b/crates/plan-selection/src/pass/subexpr/search.rs @@ -72,12 +72,12 @@ pub(crate) fn stats(memo: &Memo, build_ms: f64) -> MemoStats { /// One plan of the memo: a candidate per needed group (`None` for a group /// no chosen candidate needs, or one a fused candidate implements). -type GroupChoice = Vec>; +pub(super) type GroupChoice = Vec>; /// Every consistent choice of one candidate per needed group, parents /// before children: a group is needed when it is a query's root or a child /// of a chosen candidate, and not implemented by a chosen fused candidate. -fn group_choices(memo: &Memo) -> Option> { +pub(super) fn group_choices(memo: &Memo) -> Option> { let order = memo.topological_order()?; let parents_first: Vec = order.into_iter().rev().collect(); let n = memo.groups.len(); @@ -142,7 +142,10 @@ fn group_choices(memo: &Memo) -> Option> { /// of its coupling sites: every combination of thresholds every reader may /// read. A raw-horizon site has no rewrite and leaves its readers as they /// are; Stage 3 prices it. -fn at_thresholds(memo: &Memo, choice: &GroupChoice) -> Vec>> { +pub(super) fn at_thresholds( + memo: &Memo, + choice: &GroupChoice, +) -> Vec>> { let chosen: Vec> = choice .iter() .enumerate() @@ -280,7 +283,7 @@ fn at_thresholds(memo: &Memo, choice: &GroupChoice) -> Vec) -> Vec> { +pub(super) fn identical_expression_variants(roots: Vec) -> Vec> { let mut variants = Vec::new(); for shared in identical_expression_pass2_candidates(&roots, &sharing_candidates(&roots)) { variants.push(shared.roots); @@ -309,7 +312,7 @@ fn identical_expression_variants(roots: Vec) -> Vec> { /// candidate reads from outside its query (a rollup's finer group, a /// ranking, a host chain) is built once, by the first query below whose /// root it is, or once for all. -fn composed_roots( +pub(super) fn composed_roots( memo: &Memo, per_computation: &PerComputationCandidates, chosen: &[Option], @@ -462,7 +465,7 @@ fn substituted( /// The shared builds `plan` resized: each shared-build reader's build that /// differs from the build of the memo's candidate it was chosen as /// (`choice`). -fn resized_builds( +pub(super) fn resized_builds( memo: &Memo, choice: &GroupChoice, plan: &[Option], @@ -482,11 +485,83 @@ fn resized_builds( .collect() } +/// Offer `selector` every plan of the group choice `choice`: each +/// combination of its sites' thresholds, composed, its guarantees +/// recomposed, with each identical-expression and scan-widening variant +/// the exhaustive search lists, in every Stage 2 materialization. Logical +/// roots of the plans `selector` keeps are kept in `logical`, by id +/// (`L`). `false` when the choice breaks a joint rule (nothing +/// offered). +#[allow(clippy::too_many_arguments)] +pub(super) fn offer_group_choice( + memo: &Memo, + per_computation: &PerComputationCandidates, + choice: &GroupChoice, + entries: &[QueryWorkloadEntry], + data: &DataWorkload, + models: PlanningModels<'_>, + selector: &mut PlanSelector<'_>, + logical: &mut HashMap>, + number: &mut usize, +) -> Result { + let chosen: Vec<(GroupId, &ImplementationCandidate)> = choice + .iter() + .enumerate() + .filter_map(|(g, c)| Some((GroupId(g as u32), &memo.candidates[g][(*c)?]))) + .collect(); + if !super::windows::requirements_met(&chosen) { + return Ok(false); + } + // Scans are widened as the identical-expression rule widens them, + // every metric's at once ([`identical_expression_variants`]). + if chosen + .iter() + .any(|(_, c)| c.origin == super::CandidateOrigin::WidenedScans) + { + return Ok(false); + } + for plan in at_thresholds(memo, choice) { + let Some(roots) = composed_roots(memo, per_computation, &plan) else { + continue; + }; + // Each candidate's guarantees were composed over its own + // children's Pass 1 choice: compose them again over the chosen + // implementations. + let Ok(roots) = recompose_guarantees(&roots) else { + continue; + }; + // Shared builds are one build (the summary-capability rule), + // then identical nodes shared or not, as the exhaustive search + // lists them. + let roots = + super::capability::share_equal_builds(&roots, &resized_builds(memo, choice, &plan)); + for roots in identical_expression_variants(roots) { + *number += 1; + let id = format!("L{number}"); + let Ok(implemented) = + optimize_physical_asap(&id, &roots, entries, data, models.deployment_capabilities) + else { + continue; + }; + for (k, mut physical) in implemented.into_iter().enumerate() { + physical.id = match k { + 0 => format!("P{number}"), + k => format!("P{number}-m{k}"), + }; + selector.offer(&physical)?; + } + if selector.keeps_from_logical(&id) { + logical.insert(id, roots); + } + logical.retain(|id, _| selector.keeps_from_logical(id)); + } + } + Ok(true) +} + /// The reference search: every plan of the memo ([`group_choices`] × -/// [`at_thresholds`]), its identical nodes made one node, implemented in -/// every Stage 2 materialization and priced by Stage 3; the cheapest -/// valid one, with its logical roots and the number of physical candidates -/// checked. +/// [`at_thresholds`]), each offered as [`offer_group_choice`] offers it; +/// the cheapest valid one, with its logical roots. pub(crate) fn brute_force( per_computation: &PerComputationCandidates, entries: &[QueryWorkloadEntry], @@ -501,64 +576,17 @@ pub(crate) fn brute_force( let mut logical: HashMap> = HashMap::new(); let mut number = 0usize; for choice in &choices { - let chosen: Vec<(GroupId, &ImplementationCandidate)> = choice - .iter() - .enumerate() - .filter_map(|(g, c)| Some((GroupId(g as u32), &memo.candidates[g][(*c)?]))) - .collect(); - if !super::windows::requirements_met(&chosen) { - continue; - } - // Scans are widened as the identical-expression rule widens them, - // every metric's at once ([`identical_expression_variants`]). - if chosen - .iter() - .any(|(_, c)| c.origin == super::CandidateOrigin::WidenedScans) - { - continue; - } - for plan in at_thresholds(&memo, choice) { - let Some(roots) = composed_roots(&memo, per_computation, &plan) else { - continue; - }; - // Each candidate's guarantees were composed over its own - // children's Pass 1 choice: compose them again over the chosen - // implementations. - let Ok(roots) = recompose_guarantees(&roots) else { - continue; - }; - // Shared builds are one build (the summary-capability rule), - // then identical nodes shared or not, as the exhaustive search - // lists them. - let roots = super::capability::share_equal_builds( - &roots, - &resized_builds(&memo, choice, &plan), - ); - for roots in identical_expression_variants(roots) { - number += 1; - let id = format!("L{number}"); - let Ok(implemented) = optimize_physical_asap( - &id, - &roots, - entries, - data, - models.deployment_capabilities, - ) else { - continue; - }; - for (k, mut physical) in implemented.into_iter().enumerate() { - physical.id = match k { - 0 => format!("P{number}"), - k => format!("P{number}-m{k}"), - }; - selector.offer(&physical)?; - } - if selector.keeps_from_logical(&id) { - logical.insert(id, roots); - } - logical.retain(|id, _| selector.keeps_from_logical(id)); - } - } + offer_group_choice( + &memo, + per_computation, + choice, + entries, + data, + models, + &mut selector, + &mut logical, + &mut number, + )?; } let (mut selection, selected) = selector.finish()?; selection.search.memo = Some(memo_stats); diff --git a/crates/plan-selection/src/pass/subexpr/windows.rs b/crates/plan-selection/src/pass/subexpr/windows.rs index 060f0ec31..0a0a1b80d 100644 --- a/crates/plan-selection/src/pass/subexpr/windows.rs +++ b/crates/plan-selection/src/pass/subexpr/windows.rs @@ -960,6 +960,167 @@ pub(crate) fn requirements_met(chosen: &[(GroupId, &ImplementationCandidate)]) - .all(|(coarse, _)| rolled_coarse.contains(coarse)) } +/// One rollup requirement of [`requirements_met`] as a rule over +/// candidates, by `(group, candidate index)`: when every candidate of +/// `all_of` is chosen, one of `one_of` is (none: they may not be chosen +/// together). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RollupRule { + pub all_of: Vec<(usize, usize)>, + pub one_of: Vec<(usize, usize)>, +} + +/// The rollup requirements of [`requirements_met`] over the candidates in +/// `present` (one per group in a plan), as rules: a plan of one present +/// candidate per needed group meets every rollup requirement iff it meets +/// every rule. The joint rule's source of a coarse build is the best build +/// over the plan's nodes (each rollup undone, in group order): fewest keys +/// for an exact accumulator, most for a sketch, the first in walk order +/// among equals, so a total order over the builds present, and a build is +/// the source iff no single other candidate's build beats it. For a rollup +/// `R` of coarse build `C` reading group `f`: +/// +/// - `R` needs one of `f`'s candidates whose build is `C`'s source with `R` +/// alone, exposing the state at `R`'s site for a sketch; +/// - `R`, such a candidate `F` and a candidate `Y` of a third group whose +/// build beats `F`'s for `C` are never chosen together; +/// - for a sketch, every unrolled coarse build `C'` (of `R`'s rewritten +/// candidate, of `F`, or of a third group's `Y`) whose source would be +/// `F`'s build needs a candidate `Z` of a fourth group whose build beats +/// it for `C'`. +pub(crate) fn rollup_rules(memo: &Memo, present: &BTreeSet<(usize, usize)>) -> Vec { + let candidate = |(g, c): (usize, usize)| &memo.candidates[g][c]; + // A candidate's node in the joint rule's plan: a rollup undone. + let plan_node = |key: (usize, usize)| match &candidate(key).rollup_of { + Some(read) => Rc::clone(&read.coarse_candidate), + None => Rc::clone(&candidate(key).node), + }; + // The joint rule's source of each coarse build over `nodes`, in group + // order. + let sources = + |nodes: &[(usize, Rc)]| -> Vec<(*const OperatorNode, Rc)> { + let mut nodes = nodes.to_vec(); + nodes.sort_by_key(|(g, _)| *g); + let roots: Vec = nodes + .into_iter() + .map(|(_, node)| QueryRoot::Operator(node)) + .collect(); + rollup_candidates(&roots) + .into_iter() + .map(|r| (Rc::as_ptr(&r.coarse), r.finer)) + .collect() + }; + let source_of = |coarse: *const OperatorNode, nodes: &[(usize, Rc)]| { + sources(nodes) + .into_iter() + .find(|(c, _)| *c == coarse) + .map(|(_, finer)| finer) + }; + let mut rules = Vec::new(); + for &rollup in present { + let Some(read) = &candidate(rollup).rollup_of else { + continue; + }; + let (coarse_group, finer_group) = (rollup.0, read.finer_group.0 as usize); + let coarse = Rc::as_ptr(&read.coarse_build); + // The site the rollup requires of its finer group (its own + // `requires_sites`): the finer build's state as the finer group's + // candidates key it. + let site = read.finer_state.as_ref().and_then(|_| { + candidate(rollup) + .requires_sites + .iter() + .find(|(g, _)| g.0 as usize == finer_group) + .map(|(_, site)| *site) + }); + let r_node = (coarse_group, plan_node(rollup)); + let mut allowed = Vec::new(); + for &finer in present.iter().filter(|(g, _)| *g == finer_group) { + let f_node = (finer_group, plan_node(finer)); + let Some(build) = source_of(coarse, &[r_node.clone(), f_node.clone()]) else { + continue; + }; + let in_finer = reached(&candidate(finer).node).contains(&Rc::as_ptr(&build)); + let state_ok = site.is_none_or(|site| { + candidate(finer).coupling_sites.contains(&site) && state_key(&build) == site.key + }); + if !in_finer || !state_ok { + continue; + } + allowed.push(finer); + let others = || { + present + .iter() + .copied() + .filter(move |(g, _)| *g != coarse_group && *g != finer_group) + }; + // A third group's build that beats the finer build for `C`. + for other in others() { + let nodes = [r_node.clone(), f_node.clone(), (other.0, plan_node(other))]; + if source_of(coarse, &nodes).is_none_or(|b| !Rc::ptr_eq(&b, &build)) { + rules.push(RollupRule { + all_of: vec![rollup, finer, other], + one_of: Vec::new(), + }); + } + } + if site.is_none() { + continue; + } + // Every unrolled coarse build whose source the state is rolls up + // too, unless a fourth group's build beats it. + let mut holders: Vec> = vec![None, Some(finer)]; + holders.extend( + others() + .filter(|&o| candidate(o).rollup_of.is_none()) + .map(Some), + ); + for holder in holders { + let mut nodes = vec![r_node.clone(), f_node.clone()]; + if let Some(h) = holder.filter(|&h| h != finer) { + nodes.push((h.0, plan_node(h))); + } + let holder_node = match holder { + None => Rc::clone(&r_node.1), + Some(h) => plan_node(h), + }; + let in_holder = reached(&holder_node); + for (other_coarse, finer_build) in sources(&nodes) { + if other_coarse == coarse + || !Rc::ptr_eq(&finer_build, &build) + || !in_holder.contains(&other_coarse) + { + continue; + } + let mut all_of = vec![rollup, finer]; + if let Some(h) = holder.filter(|&h| h != finer) { + all_of.push(h); + } + let groups: Vec = all_of.iter().map(|(g, _)| *g).collect(); + let one_of = present + .iter() + .copied() + .filter(|(g, _)| !groups.contains(g)) + .filter(|&z| { + let mut with = nodes.clone(); + with.push((z.0, plan_node(z))); + source_of(other_coarse, &with).is_none_or(|b| !Rc::ptr_eq(&b, &build)) + }) + .collect(); + rules.push(RollupRule { all_of, one_of }); + } + } + } + rules.push(RollupRule { + all_of: vec![rollup], + one_of: allowed, + }); + } + rules.sort_by(|a, b| (&a.all_of, &a.one_of).cmp(&(&b.all_of, &b.one_of))); + rules.dedup(); + rules +} + /// Whether `candidate` computes exactly: no node it reaches builds an /// approximate summary (every summary an exact accumulator). pub(crate) fn is_exact(candidate: &ImplementationCandidate) -> bool { diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index 91c6bc65f..b7d77564c 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -343,6 +343,8 @@ cargo run -p asap-devtools --bin stage_pipeline -- \ | `--measured-operation-costs ` | A JSON table of measured CPU time per sketch operation (`[{"sketch": …, "operation": "update", "cpu_ns_per_operation": …}]`, as `convert_rqe_atomic_costs` writes it). A sketch node whose parameters match an entry exactly is priced from it; each node's `price_source` in the document says `measured` or `analytical`. | | `--enforce-accuracy-guarantees` | Reject a plan whose accuracy guarantee does not meet its query's target, as `plan_installable` does with the default settings. Without it (the default), every query output is accepted regardless of its guarantee, the plans are compared on cost alone, and each output's line says `regardless of guarantee`, plus `ignoring a failed guarantee check ()` when its guarantee would not have met the target; the document's `accuracy_guarantees` is `ignored` or `enforced`. With `--plan-selection-settings`, this flag decides, whatever the file's `ignore_accuracy_guarantees` says. | | `--exhaustive` | Compose and price every candidate instead of running the default search, and list them all. Use it for small workloads or to compare with the default search. | +| `--search ` | The default search's algorithm: `legacy` (the default), `memo-brute-force` (every plan of the memo over logical sub-expressions, each evaluated by Stage 3: the reference) or `memo-milp` (experimental: the memo solved as a mixed-integer linear program, cost model 1 only). The document's `search` names it (`default` for `legacy`). The search report gives `incumbent_cost` (the selected plan's cost), `algorithm` (omitted for `legacy`), `routed_to_legacy` when the legacy search ran instead (cost models 2 and 3), and for `memo-milp` a `milp` object: `lower_bound`, `first_lower_bound`, `gap` (0 when the plan is proven optimal), `rounds`, `variable_count`, `constraint_count`, `solver_time_ms` and `time_limit_reached`. Not with `--exhaustive`. | +| `--milp-time-limit-s ` | With `--search memo-milp`: stop after this many seconds and select the best plan found, reporting `time_limit_reached` and its `gap`; never called optimal unless the gap is 0. | | `--max-displayed ` | With `--exhaustive`, how many plans the document shows (default 64), in enumeration order, plus the selected plan. The planner still optimizes and prices every plan, so the selection does not depend on it. | | `--max-combinations ` | With `--exhaustive` only: the most candidates a run plans (default 10000), counted at Stage 1 and again at Stage 2; a larger workload is refused with exit status 3 and no document. The default search has no limit. | | `--out ` | Where to write the document | diff --git a/tools/dag-viewer/examples/planner-layering-example1.json b/tools/dag-viewer/examples/planner-layering-example1.json index 92fe77ccc..4de367b3c 100644 --- a/tools/dag-viewer/examples/planner-layering-example1.json +++ b/tools/dag-viewer/examples/planner-layering-example1.json @@ -129080,6 +129080,7 @@ "search": { "candidates_checked": 72, "components": [], + "incumbent_cost": 4.377000084179999, "queries": [], "raw_scan_assignments_skipped": 0, "raw_scan_candidates_skipped": 0, diff --git a/tools/dag-viewer/examples/random-mixed-seed-862-long-workload-01.json b/tools/dag-viewer/examples/random-mixed-seed-862-long-workload-01.json index b872f7690..0127bc00f 100644 --- a/tools/dag-viewer/examples/random-mixed-seed-862-long-workload-01.json +++ b/tools/dag-viewer/examples/random-mixed-seed-862-long-workload-01.json @@ -20398,6 +20398,7 @@ "search": { "candidates_checked": 933, "components": [], + "incumbent_cost": 4.5581790928001605e-6, "interchangeable_queries": [ [ 2, diff --git a/tools/dag-viewer/examples/random-mixed-seed-862-workload-01.json b/tools/dag-viewer/examples/random-mixed-seed-862-workload-01.json index be0b7c342..5c05c9ef2 100644 --- a/tools/dag-viewer/examples/random-mixed-seed-862-workload-01.json +++ b/tools/dag-viewer/examples/random-mixed-seed-862-workload-01.json @@ -20398,6 +20398,7 @@ "search": { "candidates_checked": 933, "components": [], + "incumbent_cost": 1.5764190368555072e-6, "interchangeable_queries": [ [ 2, diff --git a/tools/dag-viewer/examples/random-mixed-seed-862-x5-workload-01.json b/tools/dag-viewer/examples/random-mixed-seed-862-x5-workload-01.json index 46db8142b..5902641c1 100644 --- a/tools/dag-viewer/examples/random-mixed-seed-862-x5-workload-01.json +++ b/tools/dag-viewer/examples/random-mixed-seed-862-x5-workload-01.json @@ -20398,6 +20398,7 @@ "search": { "candidates_checked": 933, "components": [], + "incumbent_cost": 7.882484714275631e-6, "interchangeable_queries": [ [ 2,