From 9fd50063bdc7aea9dae2b22ea645c421c11fcc33 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 11:40:46 -0400 Subject: [PATCH 1/7] rqe-optimizer: rename Rqe to Raqe and accuracy_tolerance to accuracy_sla Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/examples/small_problem.rs | 112 ++++++++--------- rqe-optimizer/src/analytical_cost_model.rs | 78 ++++++------ rqe-optimizer/src/autosketch.rs | 140 +++++++++++---------- rqe-optimizer/src/candidates.rs | 99 ++++++++------- rqe-optimizer/src/enumerate.rs | 81 ++++++------ rqe-optimizer/src/lib.rs | 66 +++++----- rqe-optimizer/src/milp.rs | 111 ++++++++-------- rqe-optimizer/src/pareto.rs | 2 +- 8 files changed, 359 insertions(+), 330 deletions(-) diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index 0bfec499..2fb3949a 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -9,9 +9,9 @@ //! no translation layer, since `rqe_optimizer::Deployment` embeds //! `AtomicCostEntry` directly. //! -//! Every other number this example uses (RQE definitions, metric labels, +//! Every other number this example uses (RAQE definitions, metric labels, //! cardinalities and scrape intervals) lives in the two tables below -//! (`facts`, `rqes`) and nowhere else in this file. +//! (`facts`, `raqes`) and nowhere else in this file. //! //! Run: `scripts/export_rqe_optimizer_costs.sh` once. Do not run this example //! with no mode flag on a large workload: eager mode retains every feasible @@ -23,7 +23,7 @@ //! `--print-first N` to display example mappings. `--milp` minimizes //! `w_cpu · CPU + w_mem · memory GiB` without enumerating mappings; set the //! weights with `--w-cpu X --w-mem Y` (default 1 and 0). Repeat -//! `--latency-limit RQE_ID=SECONDS` to impose MILP latency bounds. +//! `--latency-limit RAQE_ID=SECONDS` to impose MILP latency bounds. //! `--sample-mappings N` prints N feasible mappings and exits. use std::collections::BTreeSet; @@ -37,13 +37,13 @@ use rqe_optimizer::enumerate::{brute_force, for_each_mapping, for_each_mapping_w use rqe_optimizer::milp::{minimize, MilpBounds, Objective}; use rqe_optimizer::pareto::{pareto_front, ParetoFront}; use rqe_optimizer::{ - validate_facts, AccuracyDirection, AtomicCostTable, Capability, LabelSet, MetricFacts, Rqe, + validate_facts, AccuracyDirection, AtomicCostTable, Capability, LabelSet, MetricFacts, Raqe, WorkloadFacts, }; /// Accuracy metric keys the real comparators actually report (checked /// against `aqpbm-core/src/accuracy/{aggregate,quantile,cardinality}.rs`). -/// These three are lower-is-better errors; each RQE below pairs its metric with the matching +/// These three are lower-is-better errors; each RAQE below pairs its metric with the matching /// [`AccuracyDirection`] so the comparison can't be applied the wrong way /// round. /// @@ -96,13 +96,13 @@ fn facts() -> WorkloadFacts { .into() } -fn rqes() -> Vec { +fn raqes() -> Vec { let se = label_set(&["service", "endpoint"]); let s = label_set(&["service"]); let requests = || REQUESTS.to_string(); let duration = || DURATION.to_string(); vec![ - Rqe { + Raqe { id: "req_rate_1h".to_string(), capability: Capability::RateOrIncrease, lookback_secs: 3_600, @@ -110,10 +110,10 @@ fn rqes() -> Vec { metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), - accuracy_tolerance: 0.1, + accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "req_rate_1d".to_string(), capability: Capability::RateOrIncrease, lookback_secs: 86_400, @@ -121,10 +121,10 @@ fn rqes() -> Vec { metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), - accuracy_tolerance: 0.1, + accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "req_rate_5m_tick".to_string(), capability: Capability::RateOrIncrease, lookback_secs: 3_600, @@ -132,10 +132,10 @@ fn rqes() -> Vec { metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), - accuracy_tolerance: 0.1, + accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "latency_p99_1h".to_string(), capability: Capability::Quantile, lookback_secs: 3_600, @@ -143,10 +143,10 @@ fn rqes() -> Vec { metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), - accuracy_tolerance: 0.05, + accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "latency_p99_6h_tick".to_string(), capability: Capability::Quantile, lookback_secs: 21_600, @@ -154,10 +154,10 @@ fn rqes() -> Vec { metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), - accuracy_tolerance: 0.05, + accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "latency_p99_1d".to_string(), capability: Capability::Quantile, lookback_secs: 86_400, @@ -165,10 +165,10 @@ fn rqes() -> Vec { metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), - accuracy_tolerance: 0.05, + accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, }, - Rqe { + Raqe { id: "distinct_services_1h".to_string(), capability: Capability::Cardinality, lookback_secs: 3_600, @@ -176,14 +176,14 @@ fn rqes() -> Vec { metric: requests(), grouping_labels: s.clone(), accuracy_metric: CARDINALITY_ERR.to_string(), - accuracy_tolerance: 0.1, + accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, }, // The higher-is-better case: 0.9 is a *floor* on precision@k, not a // ceiling on error. Served by cms-heap, whose measured precision is // 1.0 at both configs under the zipf workload (it was 0.0 at both // under uniform -- top-k is meaningless without real skew). - Rqe { + Raqe { id: "top_endpoints_1h".to_string(), capability: Capability::TopK, lookback_secs: 3_600, @@ -191,7 +191,7 @@ fn rqes() -> Vec { metric: requests(), grouping_labels: se.clone(), accuracy_metric: TOPK_PRECISION.to_string(), - accuracy_tolerance: 0.9, + accuracy_sla: 0.9, accuracy_direction: AccuracyDirection::HigherIsBetter, }, ] @@ -214,15 +214,15 @@ fn positive_integer_flag(name: &str, default: u64) -> u64 { fn print_mapping( mapping_number: u64, mapping: &[usize], - rqes: &[Rqe], + raqes: &[Raqe], deployments: &[rqe_optimizer::Deployment], ) { println!("mapping {mapping_number}:"); - for (rqe, &deployment_index) in rqes.iter().zip(mapping) { + for (raqe, &deployment_index) in raqes.iter().zip(mapping) { let deployment = &deployments[deployment_index]; println!( " {} -> {} {} (x={}s, y={}s)", - rqe.id, + raqe.id, deployment.config.sketch, deployment.config.sketch_config, deployment.window_secs, @@ -243,22 +243,22 @@ fn print_candidate(candidate_number: usize, deployment: &rqe_optimizer::Deployme ); } -fn latency_bounds(rqes: &[Rqe]) -> Vec> { +fn latency_bounds(raqes: &[Raqe]) -> Vec> { let args: Vec<_> = std::env::args().collect(); - let mut bounds = vec![None; rqes.len()]; + let mut bounds = vec![None; raqes.len()]; for pair in args.windows(2).filter(|pair| pair[0] == "--latency-limit") { let (id, seconds) = pair[1] .split_once('=') - .unwrap_or_else(|| panic!("--latency-limit expects RQE_ID=SECONDS")); + .unwrap_or_else(|| panic!("--latency-limit expects RAQE_ID=SECONDS")); let seconds = seconds .parse::() .ok() .filter(|value| *value > 0.0) .unwrap_or_else(|| panic!("latency limit must be a positive number of seconds")); - let index = rqes + let index = raqes .iter() - .position(|rqe| rqe.id == id) - .unwrap_or_else(|| panic!("unknown RQE in --latency-limit: {id}")); + .position(|raqe| raqe.id == id) + .unwrap_or_else(|| panic!("unknown RAQE in --latency-limit: {id}")); assert!( bounds[index].replace(seconds).is_none(), "duplicate latency limit for {id}" @@ -301,19 +301,19 @@ fn print_plan_cost(label: &str, plan_cost: &PlanCost) { fn main() { let candidates_only = std::env::args().any(|arg| arg == "--candidates-only"); - let rqes = rqes(); + let raqes = raqes(); let cost_table = load_cost_table(); let facts = facts(); - if let Err(problems) = validate_facts(&rqes, &facts) { + if let Err(problems) = validate_facts(&raqes, &facts) { panic!("invalid workload facts: {problems:#?}"); } let unpruned_count = - candidates_only.then(|| build_all_candidates_unpruned(&rqes, &cost_table, &facts).len()); - let deployments = build_all_candidates(&rqes, &cost_table, &facts); + candidates_only.then(|| build_all_candidates_unpruned(&raqes, &cost_table, &facts).len()); + let deployments = build_all_candidates(&raqes, &cost_table, &facts); println!( - "{} RQEs, {} candidate deployments (from {} real cost-table rows)", - rqes.len(), + "{} RAQEs, {} candidate deployments (from {} real cost-table rows)", + raqes.len(), deployments.len(), cost_table.len(), ); @@ -325,9 +325,9 @@ fn main() { unpruned_count - deployments.len(), ); let mut possible_mappings = Some(1_u64); - for rqe in &rqes { - let eligible = eligible_deployments_for(rqe, &deployments); - println!(" {}: {} eligible deployments", rqe.id, eligible.len()); + for raqe in &raqes { + let eligible = eligible_deployments_for(raqe, &deployments); + println!(" {}: {} eligible deployments", raqe.id, eligible.len()); possible_mappings = possible_mappings.and_then(|count| { count.checked_mul(eligible.len().try_into().expect("usize fits in u64")) }); @@ -347,7 +347,7 @@ fn main() { return; } - let missing = unservable(&rqes, &deployments); + let missing = unservable(&raqes, &deployments); if !missing.is_empty() { println!("unservable (no eligible deployment): {missing:?}"); return; @@ -356,9 +356,9 @@ fn main() { let sample_mappings = positive_integer_flag("--sample-mappings", 0); if sample_mappings > 0 { let mut printed = 0; - let result = for_each_mapping_while(&rqes, &deployments, |mapping| { + let result = for_each_mapping_while(&raqes, &deployments, |mapping| { printed += 1; - print_mapping(printed, mapping, &rqes, &deployments); + print_mapping(printed, mapping, &raqes, &deployments); printed < sample_mappings }); println!( @@ -375,22 +375,22 @@ fn main() { if std::env::args().any(|arg| arg == "--milp") { let bounds = MilpBounds { - max_query_latency_secs: latency_bounds(&rqes), + max_query_latency_secs: latency_bounds(&raqes), }; let objective = Objective::AUCCost { w_cpu: weight_flag("--w-cpu", 1.0), w_mem: weight_flag("--w-mem", 0.0), }; - let solution = minimize(&rqes, &deployments, &facts, &bounds, objective) + let solution = minimize(&raqes, &deployments, &facts, &bounds, objective) .expect("small_problem MILP should be feasible"); println!( "MILP solution for {objective:?}: {:.3e}", objective.value(&solution.plan_cost) ); print_plan_cost(" totals", &solution.plan_cost); - print_mapping(1, &solution.mapping, &rqes, &deployments); - for (rqe, latency) in rqes.iter().zip(&solution.plan_cost.query_latency_secs) { - println!(" {}: query_latency={latency:.3e} sec", rqe.id); + print_mapping(1, &solution.mapping, &raqes, &deployments); + for (raqe, latency) in raqes.iter().zip(&solution.plan_cost.query_latency_secs) { + println!(" {}: query_latency={latency:.3e} sec", raqe.id); } return; } @@ -401,11 +401,11 @@ fn main() { let print_first = positive_integer_flag("--print-first", 0); let started = Instant::now(); let mut processed = 0_u64; - let mapping_count = for_each_mapping(&rqes, &deployments, |mapping| { - front.consider(mapping, score(&rqes, &deployments, mapping, &facts)); + let mapping_count = for_each_mapping(&raqes, &deployments, |mapping| { + front.consider(mapping, score(&raqes, &deployments, mapping, &facts)); processed += 1; if processed <= print_first { - print_mapping(processed, mapping, &rqes, &deployments); + print_mapping(processed, mapping, &raqes, &deployments); } if processed.is_multiple_of(progress_every) { let elapsed_secs = started.elapsed().as_secs_f64(); @@ -428,12 +428,12 @@ fn main() { "WARNING: eager mode will retain every feasible mapping. For larger workloads, use --milp, \ --candidates-only, or --sample-mappings N instead." ); - let mappings = brute_force(&rqes, &deployments); + let mappings = brute_force(&raqes, &deployments); println!("{} feasible full mappings", mappings.len()); let plan_costs: Vec<_> = mappings .iter() - .map(|m| score(&rqes, &deployments, m, &facts)) + .map(|m| score(&raqes, &deployments, m, &facts)) .collect(); let front = pareto_front(&plan_costs); println!("{} on the Pareto front\n", front.len()); @@ -447,9 +447,9 @@ fn main() { &format!("mapping {i}: {} deployments", distinct_deployments.len()), plan_cost, ); - for (rqe, latency) in rqes.iter().zip(&plan_cost.query_latency_secs) { - let rqe_id = &rqe.id; - println!(" {rqe_id}: query_latency={latency:.3e} sec"); + for (raqe, latency) in raqes.iter().zip(&plan_cost.query_latency_secs) { + let raqe_id = &raqe.id; + println!(" {raqe_id}: query_latency={latency:.3e} sec"); } } } diff --git a/rqe-optimizer/src/analytical_cost_model.rs b/rqe-optimizer/src/analytical_cost_model.rs index 28161846..bec19aa4 100644 --- a/rqe-optimizer/src/analytical_cost_model.rs +++ b/rqe-optimizer/src/analytical_cost_model.rs @@ -15,7 +15,7 @@ // ponytail: a family with one fixed-size instance for all groups (HydraKLL, // sketch-bench#142) needs a per-family shape so it isn't multiplied by card(G). -use crate::{Capability, Deployment, Mapping, Rqe, WorkloadFacts}; +use crate::{Capability, Deployment, Mapping, Raqe, WorkloadFacts}; use std::collections::{BTreeMap, BTreeSet}; pub(crate) const BYTES_PER_GIB: f64 = (1u64 << 30) as f64; @@ -27,7 +27,7 @@ pub const OUTPUT_BYTES_PER_VALUE: f64 = 8.0; pub const OUTPUT_BYTES_PER_TOPK_ENTRY: f64 = 16.0; /// Top-k entries per group: the heap size every top-k cost row was measured /// at (sketch-bench's `CMS_HEAP_TOP_K`). -// ponytail: fixed k. A large k needs `k` on the RQE and a benchmark that +// ponytail: fixed k. A large k needs `k` on the RAQE and a benchmark that // varies the heap, whose memory the export leaves out. pub const TOPK_ENTRIES: f64 = 32.0; @@ -50,13 +50,13 @@ impl std::ops::AddAssign for PhaseCost { pub struct PlanCost { /// Paid once per active deployment. pub ingest: PhaseCost, - /// Paid per RQE; memory as if every query runs at once. + /// Paid per RAQE; memory as if every query runs at once. pub merge: PhaseCost, - /// Paid per RQE; memory as if every query runs at once. + /// Paid per RAQE; memory as if every query runs at once. pub query: PhaseCost, /// Paid once per active deployment, sized for its longest lookback. pub storage: PhaseCost, - /// Index-aligned with the input RQE slice; display IDs need not be unique. + /// Index-aligned with the input RAQE slice; display IDs need not be unique. pub query_latency_secs: Vec, } @@ -88,9 +88,9 @@ fn open_window_count(deployment: &Deployment) -> f64 { } /// `L / x`: windows merged per query. -fn merged_window_count(rqe: &Rqe, deployment: &Deployment) -> f64 { +fn merged_window_count(raqe: &Raqe, deployment: &Deployment) -> f64 { deployment - .query_instance_count(rqe.lookback_secs) + .query_instance_count(raqe.lookback_secs) .expect("only eligible pairs are costed") as f64 } @@ -110,19 +110,19 @@ pub(crate) fn ingest(deployment: &Deployment, facts: &WorkloadFacts) -> PhaseCos /// CPU `card(G) · (L/x − 1) · c_mrg / T`; memory `card(G) · m`, one /// accumulator per group. A direct query (`L == x`) merges nothing and costs /// neither. -pub(crate) fn merge(rqe: &Rqe, deployment: &Deployment, facts: &WorkloadFacts) -> PhaseCost { +pub(crate) fn merge(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) -> PhaseCost { let groups = group_count(deployment, facts); - let merges_per_group = merged_window_count(rqe, deployment) - 1.0; + let merges_per_group = merged_window_count(raqe, deployment) - 1.0; PhaseCost { cpu_secs_per_sec: groups * merges_per_group * deployment.config.merge_cpu_secs - / rqe.interval_secs as f64, - memory_bytes: groups * merge_memory_per_group(rqe, deployment), + / raqe.interval_secs as f64, + memory_bytes: groups * merge_memory_per_group(raqe, deployment), } } /// One accumulator if the query merges at all. -pub(crate) fn merge_memory_per_group(rqe: &Rqe, deployment: &Deployment) -> f64 { - if merged_window_count(rqe, deployment) > 1.0 { +pub(crate) fn merge_memory_per_group(raqe: &Raqe, deployment: &Deployment) -> f64 { + if merged_window_count(raqe, deployment) > 1.0 { deployment.config.mem_bytes_per_instance } else { 0.0 @@ -130,43 +130,47 @@ pub(crate) fn merge_memory_per_group(rqe: &Rqe, deployment: &Deployment) -> f64 } /// CPU `card(G) · c_qry / T`; memory `card(G) ·` output bytes per group. -pub(crate) fn query(rqe: &Rqe, deployment: &Deployment, facts: &WorkloadFacts) -> PhaseCost { +pub(crate) fn query(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) -> PhaseCost { let groups = group_count(deployment, facts); - let output_bytes_per_group = match rqe.capability { + let output_bytes_per_group = match raqe.capability { Capability::TopK => TOPK_ENTRIES * OUTPUT_BYTES_PER_TOPK_ENTRY, _ => OUTPUT_BYTES_PER_VALUE, }; PhaseCost { - cpu_secs_per_sec: groups * deployment.config.query_cpu_secs / rqe.interval_secs as f64, + cpu_secs_per_sec: groups * deployment.config.query_cpu_secs / raqe.interval_secs as f64, memory_bytes: groups * output_bytes_per_group, } } -/// Closed windows kept to answer `rqe`: `card(G) · m · ((L − x)/y + 1)`. -pub(crate) fn storage_bytes(rqe: &Rqe, deployment: &Deployment, facts: &WorkloadFacts) -> f64 { +/// Closed windows kept to answer `raqe`: `card(G) · m · ((L − x)/y + 1)`. +pub(crate) fn storage_bytes(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) -> f64 { let closed_windows = deployment - .closed_instance_count(rqe.lookback_secs) + .closed_instance_count(raqe.lookback_secs) .expect("only eligible pairs are costed") as f64; group_count(deployment, facts) * deployment.config.mem_bytes_per_instance * closed_windows } /// Serial CPU time of one query: `card(G) · (c_qry + (L/x − 1) · c_mrg)`. -pub(crate) fn query_latency_secs(rqe: &Rqe, deployment: &Deployment, facts: &WorkloadFacts) -> f64 { - let merges_per_group = merged_window_count(rqe, deployment) - 1.0; +pub(crate) fn query_latency_secs( + raqe: &Raqe, + deployment: &Deployment, + facts: &WorkloadFacts, +) -> f64 { + let merges_per_group = merged_window_count(raqe, deployment) - 1.0; group_count(deployment, facts) * (deployment.config.query_cpu_secs + merges_per_group * deployment.config.merge_cpu_secs) } pub fn score( - rqes: &[Rqe], + raqes: &[Raqe], deployments: &[Deployment], mapping: &Mapping, facts: &WorkloadFacts, ) -> PlanCost { assert_eq!( mapping.len(), - rqes.len(), - "mapping must have one deployment per RQE" + raqes.len(), + "mapping must have one deployment per RAQE" ); let active_deployments: BTreeSet = mapping.iter().copied().collect(); let mut ingest_cost = PhaseCost::default(); @@ -177,18 +181,18 @@ pub fn score( let mut storage_bytes_by_deployment: BTreeMap = BTreeMap::new(); let mut merge_cost = PhaseCost::default(); let mut query_cost = PhaseCost::default(); - let query_latency_secs = rqes + let query_latency_secs = raqes .iter() .zip(mapping) - .map(|(rqe, &deployment_index)| { + .map(|(raqe, &deployment_index)| { let deployment = &deployments[deployment_index]; let stored = storage_bytes_by_deployment .entry(deployment_index) .or_default(); - *stored = stored.max(storage_bytes(rqe, deployment, facts)); - merge_cost += merge(rqe, deployment, facts); - query_cost += query(rqe, deployment, facts); - query_latency_secs(rqe, deployment, facts) + *stored = stored.max(storage_bytes(raqe, deployment, facts)); + merge_cost += merge(raqe, deployment, facts); + query_cost += query(raqe, deployment, facts); + query_latency_secs(raqe, deployment, facts) }) .collect(); @@ -207,7 +211,7 @@ pub fn score( #[cfg(test)] mod tests { use super::*; - use crate::test_support::{deployment, facts, rqe}; + use crate::test_support::{deployment, facts, raqe}; #[test] fn scores_each_phase() { @@ -215,7 +219,7 @@ mod tests { // windows. Lookback 60 s every 30 s: 3 windows merged, and // (60 − 20)/10 + 1 = 5 closed windows stored. let deployment = deployment(10.0, 2.0, 3.0, 5.0, 20, 10); - let result = score(&[rqe(60, 30)], &[deployment], &vec![0], &facts(4, 7)); + let result = score(&[raqe(60, 30)], &[deployment], &vec![0], &facts(4, 7)); assert_eq!(result.ingest.cpu_secs_per_sec, 28.0); // 7 × 2 × 2 assert_eq!(result.ingest.memory_bytes, 80.0); // 4 × 10 × 2 @@ -233,7 +237,7 @@ mod tests { #[test] fn a_direct_query_stores_one_closed_window_and_never_merges() { let deployment = deployment(10.0, 1.0, 1.0, 1.0, 60, 60); - let result = score(&[rqe(60, 60)], &[deployment], &vec![0], &facts(1, 1)); + let result = score(&[raqe(60, 60)], &[deployment], &vec![0], &facts(1, 1)); assert_eq!(result.storage.memory_bytes, 10.0); assert_eq!(result.merge, PhaseCost::default()); } @@ -241,13 +245,13 @@ mod tests { #[test] fn shared_deployment_stores_for_its_longest_lookback() { let deployment = deployment(10.0, 1.0, 1.0, 1.0, 10, 10); - let rqes = [rqe(60, 10), rqe(600, 10)]; - let result = score(&rqes, &[deployment], &vec![0, 0], &facts(2, 2)); + let raqes = [raqe(60, 10), raqe(600, 10)]; + let result = score(&raqes, &[deployment], &vec![0, 0], &facts(2, 2)); // 2 groups × 10 bytes × ((600 − 10)/10 + 1) closed windows. assert_eq!(result.storage.memory_bytes, 1200.0); - // Ingest is paid once; merge once per RQE. + // Ingest is paid once; merge once per RAQE. assert_eq!(result.ingest.cpu_secs_per_sec, 2.0); - // Two RQEs × 2 groups × 10 bytes. + // Two RAQEs × 2 groups × 10 bytes. assert_eq!(result.merge.memory_bytes, 2.0 * 2.0 * 10.0); } } diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index 74c0a464..48698047 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -5,23 +5,23 @@ //! fixed CMS width/depth grid to each variant's measured parameter axes. //! //! AutoSketch plans one query at a time, never shares state across queries, -//! and constrains accuracy only. So every RQE is searched independently, +//! and constrains accuracy only. So every RAQE is searched independently, //! gets its own deployment, and latency is not considered. //! //! One extension over the paper: AutoSketch's compiler maps an operator to one //! sketch algorithm and tunes its parameters, while this search covers every -//! variant serving the RQE's capability and keeps the cheapest, so it chooses +//! variant serving the RAQE's capability and keeps the cheapest, so it chooses //! from the same sketches as the MILP. use crate::candidates::gcd; -use crate::{AtomicCostEntry, Deployment, Mapping, Rqe, Seconds}; +use crate::{AtomicCostEntry, Deployment, Mapping, Raqe, Seconds}; use std::collections::{BTreeMap, VecDeque}; use std::time::Instant; -/// One RQE's search. `selected` and `probes` index into the cost table. +/// One RAQE's search. `selected` and `probes` index into the cost table. #[derive(Debug, Clone)] -pub struct RqeSearch { - pub rqe_id: String, +pub struct RaqeSearch { + pub raqe_id: String, /// Smallest feasible configuration found; `None` means unservable. pub selected: Option, /// Distinct configurations whose accuracy was evaluated, in order. @@ -29,52 +29,55 @@ pub struct RqeSearch { pub search_wall_secs: f64, } -/// A whole-workload plan: one dedicated deployment per RQE, so identical -/// choices are still paid for once per RQE, and the identity mapping. +/// A whole-workload plan: one dedicated deployment per RAQE, so identical +/// choices are still paid for once per RAQE, and the identity mapping. #[derive(Debug, Clone)] pub struct AutoSketchPlan { pub deployments: Vec, pub mapping: Mapping, - pub searches: Vec, + pub searches: Vec, } /// One sliding sketch per query window: `x = S`, `y = T`, or `gcd(S, T)` /// when `T` does not divide `S`, so the window still tiles into slides. -pub fn window_adapter(rqe: &Rqe) -> (Seconds, Seconds) { - (rqe.lookback_secs, gcd(rqe.lookback_secs, rqe.interval_secs)) +pub fn window_adapter(raqe: &Raqe) -> (Seconds, Seconds) { + ( + raqe.lookback_secs, + gcd(raqe.lookback_secs, raqe.interval_secs), + ) } -/// Search every RQE independently. `accuracy` is the benchmark oracle: the -/// measured metric named by `rqe.accuracy_metric` for a configuration, or -/// `None` when it was not measured. Returns the IDs of unservable RQEs. +/// Search every RAQE independently. `accuracy` is the benchmark oracle: the +/// measured metric named by `raqe.accuracy_metric` for a configuration, or +/// `None` when it was not measured. Returns the IDs of unservable RAQEs. pub fn plan( - rqes: &[Rqe], + raqes: &[Raqe], costs: &[AtomicCostEntry], seed: u64, - mut accuracy: impl FnMut(&Rqe, &AtomicCostEntry) -> Option, + mut accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, ) -> Result> { - let searches: Vec<_> = rqes + let searches: Vec<_> = raqes .iter() - .map(|rqe| search(rqe, costs, seed, &mut accuracy)) + .map(|raqe| search(raqe, costs, seed, &mut accuracy)) .collect(); let unservable: Vec<_> = searches .iter() .filter(|s| s.selected.is_none()) - .map(|s| s.rqe_id.clone()) + .map(|s| s.raqe_id.clone()) .collect(); if !unservable.is_empty() { return Err(unservable); } - let deployments = rqes + let deployments = raqes .iter() .zip(&searches) - .map(|(rqe, s)| { - let (window_secs, slide_secs) = window_adapter(rqe); + .map(|(raqe, s)| { + let (window_secs, slide_secs) = window_adapter(raqe); Deployment { - capability: rqe.capability, - metric: rqe.metric.clone(), - grouping_labels: rqe.grouping_labels.clone(), - config: costs[s.selected.expect("unservable RQEs returned above")].clone(), + capability: raqe.capability, + metric: raqe.metric.clone(), + grouping_labels: raqe.grouping_labels.clone(), + config: costs[s.selected.expect("unservable RAQEs returned above")].clone(), window_secs, slide_secs, } @@ -82,22 +85,22 @@ pub fn plan( .collect(); Ok(AutoSketchPlan { deployments, - mapping: (0..rqes.len()).collect(), + mapping: (0..raqes.len()).collect(), searches, }) } -/// Algorithm 4 for one RQE: LHS seeds per sketch variant, then +/// Algorithm 4 for one RAQE: LHS seeds per sketch variant, then /// feasibility-directed neighbor search with the paper's pruning and /// stopping rules. Minimizes memory per instance, then insert CPU. pub fn search( - rqe: &Rqe, + raqe: &Raqe, costs: &[AtomicCostEntry], seed: u64, - mut accuracy: impl FnMut(&Rqe, &AtomicCostEntry) -> Option, -) -> RqeSearch { + mut accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, +) -> RaqeSearch { let started = Instant::now(); - let grids: Vec = rqe + let grids: Vec = raqe .capability .families() .iter() @@ -132,8 +135,8 @@ pub fn search( continue; } probes.push(index); - let feasible = accuracy(rqe, &costs[index]) - .is_some_and(|value| value.is_finite() && rqe.accuracy_ok(value)); + let feasible = accuracy(raqe, &costs[index]) + .is_some_and(|value| value.is_finite() && raqe.accuracy_ok(value)); evaluated.insert(index, feasible); if feasible && best.is_none_or(|b| cheaper(index, b)) { best = Some(index); @@ -163,8 +166,8 @@ pub fn search( } } - RqeSearch { - rqe_id: rqe.id.clone(), + RaqeSearch { + raqe_id: raqe.id.clone(), selected: best, probes, search_wall_secs: started.elapsed().as_secs_f64(), @@ -351,8 +354,8 @@ mod tests { .collect() } - fn rqe(id: &str, lookback: Seconds, interval: Seconds, tolerance: f64) -> Rqe { - Rqe { + fn raqe(id: &str, lookback: Seconds, interval: Seconds, tolerance: f64) -> Raqe { + Raqe { id: id.into(), capability: Capability::TopK, lookback_secs: lookback, @@ -360,18 +363,18 @@ mod tests { metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: ERR.into(), - accuracy_tolerance: tolerance, + accuracy_sla: tolerance, accuracy_direction: AccuracyDirection::LowerIsBetter, } } - fn table_accuracy(rqe: &Rqe, config: &AtomicCostEntry) -> Option { - config.query_accuracy.get(&rqe.accuracy_metric).copied() + fn table_accuracy(raqe: &Raqe, config: &AtomicCostEntry) -> Option { + config.query_accuracy.get(&raqe.accuracy_metric).copied() } - fn cheapest_feasible(rqe: &Rqe, costs: &[AtomicCostEntry]) -> usize { + fn cheapest_feasible(raqe: &Raqe, costs: &[AtomicCostEntry]) -> usize { (0..costs.len()) - .filter(|&i| rqe.accuracy_ok_for(&costs[i])) + .filter(|&i| raqe.accuracy_ok_for(&costs[i])) .min_by(|&a, &b| compare_resources(&costs[a], &costs[b])) .unwrap() } @@ -381,7 +384,7 @@ mod tests { let costs = grid(&[1, 2, 3], &[100, 200, 400, 800]); // Needs rows * cols >= 1000: rows=3 cols=400 (1200 counters) is the // smallest; rows=2 cols=800 and rows=3 cols=800 also pass. - let r = rqe("r", 3_600, 60, 1.0 / 1_000.0); + let r = raqe("r", 3_600, 60, 1.0 / 1_000.0); let found = search(&r, &costs, 7, table_accuracy); assert_eq!(found.selected, Some(cheapest_feasible(&r, &costs))); } @@ -391,7 +394,7 @@ mod tests { let rows: Vec = (1..=8).collect(); let cols: Vec = (0..8).map(|i| 64 << i).collect(); let costs = grid(&rows, &cols); - let r = rqe("r", 3_600, 60, 1.0 / 2_000.0); + let r = raqe("r", 3_600, 60, 1.0 / 2_000.0); let found = search(&r, &costs, 42, table_accuracy); assert!(found.selected.is_some()); assert!( @@ -406,15 +409,15 @@ mod tests { #[test] fn never_shares_identical_choices() { let costs = grid(&[2, 3], &[256, 512]); - let rqes = vec![rqe("a", 3_600, 60, 0.01), rqe("b", 3_600, 60, 0.01)]; - let plan = plan(&rqes, &costs, 1, table_accuracy).unwrap(); + let raqes = vec![raqe("a", 3_600, 60, 0.01), raqe("b", 3_600, 60, 0.01)]; + let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); assert_eq!(plan.deployments.len(), 2); assert_eq!(plan.deployments[0], plan.deployments[1]); assert_eq!(plan.mapping, vec![0, 1]); let facts = facts(1, 10); - let both = score(&rqes, &plan.deployments, &plan.mapping, &facts); - let one = score(&rqes[..1], &plan.deployments[..1], &vec![0], &facts); + let both = score(&raqes, &plan.deployments, &plan.mapping, &facts); + let one = score(&raqes[..1], &plan.deployments[..1], &vec![0], &facts); assert_eq!( both.ingest, PhaseCost { @@ -428,24 +431,27 @@ mod tests { fn adapted_window_is_eligible() { let costs = grid(&[2, 3], &[256, 512]); // T divides S, and T does not divide S (gcd slide). - let rqes = vec![rqe("tiled", 3_600, 60, 0.01), rqe("gcd", 3_600, 280, 0.01)]; - let plan = plan(&rqes, &costs, 1, table_accuracy).unwrap(); - assert_eq!(window_adapter(&rqes[0]), (3_600, 60)); - assert_eq!(window_adapter(&rqes[1]), (3_600, 40)); - for (r, d) in rqes.iter().zip(&plan.deployments) { + let raqes = vec![ + raqe("tiled", 3_600, 60, 0.01), + raqe("gcd", 3_600, 280, 0.01), + ]; + let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); + assert_eq!(window_adapter(&raqes[0]), (3_600, 60)); + assert_eq!(window_adapter(&raqes[1]), (3_600, 40)); + for (r, d) in raqes.iter().zip(&plan.deployments) { assert!(is_eligible(r, d), "{} not eligible", r.id); } } #[test] - fn reports_unservable_rqe() { + fn reports_unservable_raqe() { let costs = grid(&[2, 3], &[256, 512]); - let rqes = vec![ - rqe("ok", 3_600, 60, 0.01), - rqe("too_strict", 3_600, 60, 1e-9), + let raqes = vec![ + raqe("ok", 3_600, 60, 0.01), + raqe("too_strict", 3_600, 60, 1e-9), ]; assert_eq!( - plan(&rqes, &costs, 1, table_accuracy).unwrap_err(), + plan(&raqes, &costs, 1, table_accuracy).unwrap_err(), vec!["too_strict".to_string()] ); } @@ -453,7 +459,7 @@ mod tests { #[test] fn missing_measurement_is_infeasible() { let costs = grid(&[2, 3], &[256, 512]); - let r = rqe("r", 3_600, 60, 0.01); + let r = raqe("r", 3_600, 60, 0.01); let found = search(&r, &costs, 1, |_, _| None); assert_eq!(found.selected, None); assert_eq!(found.probes.len(), costs.len()); @@ -471,10 +477,10 @@ mod tests { } } - fn quantile_rqe(tolerance: f64) -> Rqe { - Rqe { + fn quantile_raqe(tolerance: f64) -> Raqe { + Raqe { capability: Capability::Quantile, - ..rqe("q", 3_600, 60, tolerance) + ..raqe("q", 3_600, 60, tolerance) } } @@ -484,7 +490,7 @@ mod tests { // because the seed is measured. A feasible seed shrinks to k = 5 and // stops; an infeasible one grows to k = 6 and stops. let costs: Vec<_> = (1..=10).map(kll).collect(); - let r = quantile_rqe(1.0 / 6.0); + let r = quantile_raqe(1.0 / 6.0); for seed in 0..20 { let found = search(&r, &costs, seed, table_accuracy); let ks: Vec = found.probes.iter().map(|&i| i as u64 + 1).collect(); @@ -506,7 +512,7 @@ mod tests { cs.sketch = "countsketch-heap-topk-fastpath-vector2d".into(); cs.mem_bytes_per_instance = 100.0; costs.push(cs); - let r = rqe("r", 3_600, 60, 0.01); + let r = raqe("r", 3_600, 60, 0.01); let found = search(&r, &costs, 3, table_accuracy); assert_eq!(found.selected, Some(costs.len() - 1)); } @@ -521,9 +527,9 @@ mod tests { c }) .collect(); - let r = Rqe { + let r = Raqe { accuracy_direction: AccuracyDirection::HigherIsBetter, - ..quantile_rqe(0.95) + ..quantile_raqe(0.95) }; let found = search(&r, &costs, 11, table_accuracy); assert_eq!(found.selected, Some(4)); // k = 5: 0.95 diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index bf522f13..67f52fbd 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -1,7 +1,7 @@ //! Candidate generation and eligibility (§3). use crate::analytical_cost_model::merge_memory_per_group; -use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Rqe, Seconds, WorkloadFacts}; +use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Raqe, Seconds, WorkloadFacts}; use std::collections::{BTreeMap, BTreeSet}; pub fn gcd(a: Seconds, b: Seconds) -> Seconds { @@ -44,7 +44,7 @@ fn subset_gcds(values: impl IntoIterator) -> BTreeSet { /// Windows and slides are multiples of the metric's scrape interval: anything /// finer only splits one scrape's samples. fn candidate_deployments( - group: &[&Rqe], + group: &[&Raqe], costs: &[AtomicCostEntry], scrape_interval_secs: Seconds, ) -> Vec { @@ -59,7 +59,7 @@ fn candidate_deployments( .collect::>(); let mut deployments = Vec::new(); for window_secs in windows { - // gcd(x, T) is the largest slide that serves an RQE. For RQEs sharing a + // gcd(x, T) is the largest slide that serves an RAQE. For RAQEs sharing a // deployment, the gcd of theirs is strictly better than any finer // slide that serves them all. Any subset might share. let slides = subset_gcds( @@ -90,11 +90,11 @@ fn candidate_deployments( } pub fn build_all_candidates( - rqes: &[Rqe], + raqes: &[Raqe], costs: &[AtomicCostEntry], facts: &WorkloadFacts, ) -> Vec { - prune_dominated_candidates(rqes, build_all_candidates_unpruned(rqes, costs, facts)) + prune_dominated_candidates(raqes, build_all_candidates_unpruned(raqes, costs, facts)) } /// Generate the complete v1 candidate set before dominance pruning. @@ -102,16 +102,16 @@ pub fn build_all_candidates( /// The public optimizer entry point is [`build_all_candidates`]. This helper /// exists so diagnostics can report exactly how much safe pruning removed. pub fn build_all_candidates_unpruned( - rqes: &[Rqe], + raqes: &[Raqe], costs: &[AtomicCostEntry], facts: &WorkloadFacts, ) -> Vec { - let mut groups: BTreeMap<(Capability, &str, &LabelSet), Vec<&Rqe>> = BTreeMap::new(); - for rqe in rqes { + let mut groups: BTreeMap<(Capability, &str, &LabelSet), Vec<&Raqe>> = BTreeMap::new(); + for raqe in raqes { groups - .entry((rqe.capability, &rqe.metric, &rqe.grouping_labels)) + .entry((raqe.capability, &raqe.metric, &raqe.grouping_labels)) .or_default() - .push(rqe); + .push(raqe); } groups .iter() @@ -127,12 +127,17 @@ pub fn build_all_candidates_unpruned( /// This comparison is deliberately local to a (capability, metric, grouping) /// group, where `card(G)`, arrival rate and query output size are common /// multipliers. So it compares ingest CPU and memory per group, then latency -/// (merge and query CPU), merge memory and stored memory for each RQE the +/// (merge and query CPU), merge memory and stored memory for each RAQE the /// dominated candidate can serve. -pub fn prune_dominated_candidates(rqes: &[Rqe], candidates: Vec) -> Vec { +pub fn prune_dominated_candidates(raqes: &[Raqe], candidates: Vec) -> Vec { let eligibility: Vec> = candidates .iter() - .map(|candidate| rqes.iter().map(|rqe| is_eligible(rqe, candidate)).collect()) + .map(|candidate| { + raqes + .iter() + .map(|raqe| is_eligible(raqe, candidate)) + .collect() + }) .collect(); candidates @@ -148,7 +153,7 @@ pub fn prune_dominated_candidates(rqes: &[Rqe], candidates: Vec) -> &eligibility[other_index], candidate, &eligibility[candidate_index], - rqes, + raqes, ) { Dominance::StrictlyBetter => true, // The earlier of two identical candidates wins, so a tie @@ -168,14 +173,14 @@ enum Dominance { NotDominating, } -/// Whether `replacement` can stand in for `original`: it serves every RQE +/// Whether `replacement` can stand in for `original`: it serves every RAQE /// `original` serves and is no worse on any cost. fn dominance( replacement: &Deployment, replacement_coverage: &[bool], original: &Deployment, original_coverage: &[bool], - rqes: &[Rqe], + raqes: &[Raqe], ) -> Dominance { let same_group = replacement.capability == original.capability && replacement.metric == original.metric @@ -193,22 +198,22 @@ fn dominance( (ingest_cpu(replacement), ingest_cpu(original)), (ingest_memory(replacement), ingest_memory(original)), ]; - for (rqe, _) in rqes + for (raqe, _) in raqes .iter() .zip(original_coverage) .filter(|(_, &serves)| serves) { costs.push(( - query_latency(replacement, rqe), - query_latency(original, rqe), + query_latency(replacement, raqe), + query_latency(original, raqe), )); costs.push(( - merge_memory_per_group(rqe, replacement), - merge_memory_per_group(rqe, original), + merge_memory_per_group(raqe, replacement), + merge_memory_per_group(raqe, original), )); costs.push(( - stored_memory(replacement, rqe), - stored_memory(original, rqe), + stored_memory(replacement, raqe), + stored_memory(original, raqe), )); } @@ -248,23 +253,23 @@ fn ingest_memory(deployment: &Deployment) -> f64 { open_window_count(deployment) * deployment.config.mem_bytes_per_instance } -fn query_latency(deployment: &Deployment, rqe: &Rqe) -> f64 { +fn query_latency(deployment: &Deployment, raqe: &Raqe) -> f64 { let instances = deployment - .query_instance_count(rqe.lookback_secs) - .expect("candidate coverage only contains exactly tiled RQEs"); + .query_instance_count(raqe.lookback_secs) + .expect("candidate coverage only contains exactly tiled RAQEs"); deployment.config.query_cpu_secs + instances.saturating_sub(1) as f64 * deployment.config.merge_cpu_secs } -/// Closed-window bytes per group when serving `rqe`. -fn stored_memory(deployment: &Deployment, rqe: &Rqe) -> f64 { +/// Closed-window bytes per group when serving `raqe`. +fn stored_memory(deployment: &Deployment, raqe: &Raqe) -> f64 { deployment - .closed_instance_count(rqe.lookback_secs) - .expect("candidate coverage only contains exactly tiled RQEs") as f64 + .closed_instance_count(raqe.lookback_secs) + .expect("candidate coverage only contains exactly tiled RAQEs") as f64 * deployment.config.mem_bytes_per_instance } -pub fn eligible_deployments_for(r: &Rqe, deployments: &[Deployment]) -> Vec { +pub fn eligible_deployments_for(r: &Raqe, deployments: &[Deployment]) -> Vec { deployments .iter() .enumerate() @@ -273,7 +278,7 @@ pub fn eligible_deployments_for(r: &Rqe, deployments: &[Deployment]) -> Vec bool { +pub fn is_eligible(r: &Raqe, d: &Deployment) -> bool { r.capability == d.capability && r.metric == d.metric && r.grouping_labels == d.grouping_labels @@ -291,8 +296,8 @@ mod tests { use crate::test_support::{facts, METRIC}; use crate::{AccuracyDirection, LabelSet}; use std::collections::BTreeMap; - fn rqe(id: &str, lookback: Seconds, interval: Seconds) -> Rqe { - Rqe { + fn raqe(id: &str, lookback: Seconds, interval: Seconds) -> Raqe { + Raqe { id: id.into(), capability: Capability::TopK, lookback_secs: lookback, @@ -300,7 +305,7 @@ mod tests { metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), - accuracy_tolerance: 1.0, + accuracy_sla: 1.0, accuracy_direction: AccuracyDirection::LowerIsBetter, } } @@ -317,8 +322,8 @@ mod tests { } #[test] fn shared_slide_comes_from_subset_gcd_not_all_divisors() { - let rqes = vec![rqe("a", 60, 20), rqe("b", 60, 30)]; - let candidates = build_all_candidates(&rqes, &[cost()], &facts(1, 1)); + let raqes = vec![raqe("a", 60, 20), raqe("b", 60, 30)]; + let candidates = build_all_candidates(&raqes, &[cost()], &facts(1, 1)); let slides: BTreeSet<_> = candidates .iter() .filter(|d| d.window_secs == 60) @@ -328,10 +333,10 @@ mod tests { } #[test] fn windows_and_slides_are_multiples_of_the_scrape_interval() { - let rqes = vec![rqe("a", 60, 20), rqe("b", 60, 30)]; + let raqes = vec![raqe("a", 60, 20), raqe("b", 60, 30)]; let mut facts = facts(1, 1); facts.get_mut(METRIC).unwrap().scrape_interval_secs = 15; - let candidates = build_all_candidates_unpruned(&rqes, &[cost()], &facts); + let candidates = build_all_candidates_unpruned(&raqes, &[cost()], &facts); assert!(!candidates.is_empty()); // Without the filter, windows 1..60 and slides 10/20 would appear. assert!(candidates @@ -340,9 +345,9 @@ mod tests { } #[test] fn a_min_query_is_only_offered_the_min_accumulator() { - let r = Rqe { + let r = Raqe { capability: Capability::Min, - ..rqe("r", 60, 60) + ..raqe("r", 60, 60) }; let named = |sketch: &str| AtomicCostEntry { sketch: sketch.into(), @@ -359,7 +364,7 @@ mod tests { #[test] fn eligibility_requires_exact_non_overlapping_tiling() { - let r = rqe("r", 600, 180); + let r = raqe("r", 600, 180); let d = Deployment { capability: Capability::TopK, metric: METRIC.into(), @@ -386,8 +391,8 @@ mod tests { } #[test] - fn prunes_finer_slide_when_the_coarser_slide_serves_the_same_rqe() { - let r = rqe("r", 60, 60); + fn prunes_finer_slide_when_the_coarser_slide_serves_the_same_raqe() { + let r = raqe("r", 60, 60); let coarse = Deployment { capability: Capability::TopK, metric: METRIC.into(), @@ -408,7 +413,7 @@ mod tests { #[test] fn retains_candidate_with_lower_query_latency() { - let r = rqe("r", 60, 60); + let r = raqe("r", 60, 60); let cost = cost(); let large_window = Deployment { capability: Capability::TopK, @@ -432,7 +437,7 @@ mod tests { #[test] fn retains_candidate_with_lower_stored_memory() { - let r = rqe("r", 60, 60); + let r = raqe("r", 60, 60); // Stores (60 − 60) / 60 + 1 = 1 closed instance of 10 bytes. let whole_window = Deployment { capability: Capability::TopK, @@ -465,7 +470,7 @@ mod tests { #[test] fn retains_direct_query_candidate_that_never_merges() { - let r = rqe("r", 60, 60); + let r = raqe("r", 60, 60); // x == L: no merge, so no merge memory. let direct = Deployment { capability: Capability::TopK, diff --git a/rqe-optimizer/src/enumerate.rs b/rqe-optimizer/src/enumerate.rs index 5f435b9e..ad758dfe 100644 --- a/rqe-optimizer/src/enumerate.rs +++ b/rqe-optimizer/src/enumerate.rs @@ -1,32 +1,35 @@ //! Brute-force search over full mappings (§4, §7 step 2). //! -//! A mapping is one deployment choice per RQE (§4's only constraint is -//! `Σ_D x_{i,D} = 1`; `y_D` is derived). No RQE is eligible outside its own +//! A mapping is one deployment choice per RAQE (§4's only constraint is +//! `Σ_D x_{i,D} = 1`; `y_D` is derived). No RAQE is eligible outside its own //! `(capability, metric, grouping_labels)` group, so choices are independent and the search -//! space is exactly the cartesian product of the per-RQE eligible lists -- +//! space is exactly the cartesian product of the per-RAQE eligible lists -- //! nothing to prune against. Sharing falls out when two choices land on the //! same index. //! //! An ILP would replace this module alone: `Mapping` already encodes //! `x_{i,D}` and `y_D`. -use crate::{candidates::eligible_deployments_for, Deployment, Mapping, Rqe}; +use crate::{candidates::eligible_deployments_for, Deployment, Mapping, Raqe}; -/// RQEs nothing in `deployments` can serve (§3.4). Surfaced separately from -/// `brute_force`: an unservable RQE is a modeling gap (tolerance too tight, +/// RAQEs nothing in `deployments` can serve (§3.4). Surfaced separately from +/// `brute_force`: an unservable RAQE is a modeling gap (tolerance too tight, /// no such config), not a normal empty result. -pub fn unservable(rqes: &[Rqe], deployments: &[Deployment]) -> Vec { - rqes.iter() +pub fn unservable(raqes: &[Raqe], deployments: &[Deployment]) -> Vec { + raqes + .iter() .filter(|r| eligible_deployments_for(r, deployments).is_empty()) .map(|r| r.id.clone()) .collect() } -/// Every feasible full mapping. Empty if any RQE is unservable -- check +/// Every feasible full mapping. Empty if any RAQE is unservable -- check /// [`unservable`] to tell the two cases apart. -pub fn brute_force(rqes: &[Rqe], deployments: &[Deployment]) -> Vec { +pub fn brute_force(raqes: &[Raqe], deployments: &[Deployment]) -> Vec { let mut mappings = Vec::new(); - for_each_mapping(rqes, deployments, |mapping| mappings.push(mapping.to_vec())); + for_each_mapping(raqes, deployments, |mapping| { + mappings.push(mapping.to_vec()) + }); mappings } @@ -34,11 +37,11 @@ pub fn brute_force(rqes: &[Rqe], deployments: &[Deployment]) -> Vec { /// product. The visitor receives a borrowed, index-aligned mapping that is /// valid only for the duration of the call. Returns the number visited. pub fn for_each_mapping( - rqes: &[Rqe], + raqes: &[Raqe], deployments: &[Deployment], mut visit: impl FnMut(&Mapping), ) -> u64 { - for_each_mapping_while(rqes, deployments, |mapping| { + for_each_mapping_while(raqes, deployments, |mapping| { visit(mapping); true }) @@ -57,11 +60,11 @@ pub struct EnumerationResult { /// and bounded inspection modes safe: they never need to enumerate the rest /// of the Cartesian product after collecting enough examples. pub fn for_each_mapping_while( - rqes: &[Rqe], + raqes: &[Raqe], deployments: &[Deployment], mut visit: impl FnMut(&Mapping) -> bool, ) -> EnumerationResult { - let choices: Vec> = rqes + let choices: Vec> = raqes .iter() .map(|r| eligible_deployments_for(r, deployments)) .collect(); @@ -97,7 +100,7 @@ pub fn for_each_mapping_while( let mut visited = 0; let completed = visit_depth_first( &choices, - &mut Vec::with_capacity(rqes.len()), + &mut Vec::with_capacity(raqes.len()), &mut visit, &mut visited, ); @@ -126,12 +129,12 @@ mod tests { } } - fn build_all_candidates(rqes: &[Rqe], costs: &[AtomicCostEntry]) -> Vec { - candidates::build_all_candidates(rqes, costs, &facts(1, 1)) + fn build_all_candidates(raqes: &[Raqe], costs: &[AtomicCostEntry]) -> Vec { + candidates::build_all_candidates(raqes, costs, &facts(1, 1)) } - fn rqe(id: &str, interval: u64, lookback: u64) -> Rqe { - Rqe { + fn raqe(id: &str, interval: u64, lookback: u64) -> Raqe { + Raqe { id: id.to_string(), capability: Capability::TopK, lookback_secs: lookback, @@ -139,42 +142,42 @@ mod tests { metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".to_string(), - accuracy_tolerance: 1.0, + accuracy_sla: 1.0, accuracy_direction: crate::AccuracyDirection::LowerIsBetter, } } #[test] - fn two_rqes_can_share_one_deployment() { - let rqes = vec![rqe("a", 60, 3_600), rqe("b", 60, 3_600)]; + fn two_raqes_can_share_one_deployment() { + let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; let table = vec![cost("cms-heap-topk-fastpath-vector2d")]; - let deployments = build_all_candidates(&rqes, &table); + let deployments = build_all_candidates(&raqes, &table); - assert!(unservable(&rqes, &deployments).is_empty()); - let mappings = brute_force(&rqes, &deployments); + assert!(unservable(&raqes, &deployments).is_empty()); + let mappings = brute_force(&raqes, &deployments); assert!( mappings.iter().any(|m| m[0] == m[1]), - "expected at least one mapping where both identical RQEs land on the same deployment" + "expected at least one mapping where both identical RAQEs land on the same deployment" ); } #[test] - fn unservable_rqe_yields_no_mappings() { - let rqes = vec![rqe("a", 60, 3_600)]; + fn unservable_raqe_yields_no_mappings() { + let raqes = vec![raqe("a", 60, 3_600)]; let table: Vec = Vec::new(); // no configs at all - let deployments = build_all_candidates(&rqes, &table); + let deployments = build_all_candidates(&raqes, &table); - assert_eq!(unservable(&rqes, &deployments), vec!["a".to_string()]); - assert!(brute_force(&rqes, &deployments).is_empty()); + assert_eq!(unservable(&raqes, &deployments), vec!["a".to_string()]); + assert!(brute_force(&raqes, &deployments).is_empty()); } #[test] fn streaming_visits_the_same_mappings_as_eager_enumeration() { - let rqes = vec![rqe("a", 60, 3_600), rqe("b", 60, 3_600)]; - let deployments = build_all_candidates(&rqes, &[cost("cms-heap-topk-fastpath-vector2d")]); - let eager = brute_force(&rqes, &deployments); + let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; + let deployments = build_all_candidates(&raqes, &[cost("cms-heap-topk-fastpath-vector2d")]); + let eager = brute_force(&raqes, &deployments); let mut streamed = Vec::new(); - let count = for_each_mapping(&rqes, &deployments, |mapping| { + let count = for_each_mapping(&raqes, &deployments, |mapping| { streamed.push(mapping.clone()) }); assert_eq!(count as usize, eager.len()); @@ -183,10 +186,10 @@ mod tests { #[test] fn streaming_can_stop_after_a_fixed_number_of_mappings() { - let rqes = vec![rqe("a", 60, 3_600), rqe("b", 60, 3_600)]; - let deployments = build_all_candidates(&rqes, &[cost("cms-heap-topk-fastpath-vector2d")]); + let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; + let deployments = build_all_candidates(&raqes, &[cost("cms-heap-topk-fastpath-vector2d")]); let mut sampled = Vec::new(); - let result = for_each_mapping_while(&rqes, &deployments, |mapping| { + let result = for_each_mapping_while(&raqes, &deployments, |mapping| { sampled.push(mapping.clone()); sampled.len() < 2 }); diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 4fefad39..42796b8a 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -1,9 +1,9 @@ -//! v1 brute-force solver for the RQE -> sketch-deployment mapping problem. +//! v1 brute-force solver for the RAQE -> sketch-deployment mapping problem. //! `docs/rqe_sketch_deployment_v1.md` is the problem statement; doc comments //! cite its section numbers. //! //! Three independent stages, so an ILP can replace [`enumerate`] alone: -//! [`candidates`] builds 𝒟 and per-RQE eligibility (§3), [`enumerate`] +//! [`candidates`] builds 𝒟 and per-RAQE eligibility (§3), [`enumerate`] //! searches (§4), [`analytical_cost_model`] scores (§5). //! //! Every number the algorithm uses is caller-supplied, except the query @@ -27,7 +27,7 @@ pub type Seconds = u64; /// A group-by key, compared as a set (§3's `labels_i == labels_D` rule). pub type LabelSet = BTreeSet; -/// What an RQE's statistic needs (§1). `families()` is the only place +/// What an RAQE's statistic needs (§1). `families()` is the only place /// capability scope is encoded; widening it means editing that match arm. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Capability { @@ -71,7 +71,7 @@ impl Capability { } } -/// Facts about one metric, given as input. RQEs and deployments on the same +/// Facts about one metric, given as input. RAQEs and deployments on the same /// metric read the same samples. #[derive(Debug, Clone)] pub struct MetricFacts { @@ -94,12 +94,12 @@ impl MetricFacts { /// [`MetricFacts`] keyed by metric name. pub type WorkloadFacts = BTreeMap; -/// Every problem with `facts` for serving `rqes`. The rest of the crate +/// Every problem with `facts` for serving `raqes`. The rest of the crate /// indexes `facts` without checks, so call this first on caller input. -pub fn validate_facts(rqes: &[Rqe], facts: &WorkloadFacts) -> Result<(), Vec> { +pub fn validate_facts(raqes: &[Raqe], facts: &WorkloadFacts) -> Result<(), Vec> { let mut problems = BTreeSet::new(); - for rqe in rqes { - let (metric, grouping) = (&rqe.metric, &rqe.grouping_labels); + for raqe in raqes { + let (metric, grouping) = (&raqe.metric, &raqe.grouping_labels); let Some(metric_facts) = facts.get(metric) else { problems.insert(format!("{metric}: no facts for this metric")); continue; @@ -142,7 +142,7 @@ pub fn validate_facts(rqes: &[Rqe], facts: &WorkloadFacts) -> Result<(), Vec bool { match self.accuracy_direction { - AccuracyDirection::LowerIsBetter => value <= self.accuracy_tolerance, - AccuracyDirection::HigherIsBetter => value >= self.accuracy_tolerance, + AccuracyDirection::LowerIsBetter => value <= self.accuracy_sla, + AccuracyDirection::HigherIsBetter => value >= self.accuracy_sla, } } - /// Whether `config` measured this RQE's metric and clears it. Missing + /// Whether `config` measured this RAQE's metric and clears it. Missing /// is not passing. pub fn accuracy_ok_for(&self, config: &AtomicCostEntry) -> bool { config @@ -237,7 +237,7 @@ impl Deployment { } /// A full mapping: one deployment index (into the `deployments` slice passed -/// to `enumerate::brute_force`) per RQE, aligned with the `rqes` slice's +/// to `enumerate::brute_force`) per RAQE, aligned with the `raqes` slice's /// order. `z_{i,D} = 1 <=> mapping[i] == D`'s index; `u_D = 1 <=>` `D`'s /// index appears anywhere in `mapping` (§4) -- `y` is never stored /// separately, it's always derived from `mapping`. @@ -265,8 +265,8 @@ pub(crate) mod test_support { )]) } - pub fn rqe(lookback_secs: Seconds, interval_secs: Seconds) -> Rqe { - Rqe { + pub fn raqe(lookback_secs: Seconds, interval_secs: Seconds) -> Raqe { + Raqe { id: "r".into(), capability: Capability::TopK, lookback_secs, @@ -274,7 +274,7 @@ pub(crate) mod test_support { metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), - accuracy_tolerance: 1.0, + accuracy_sla: 1.0, accuracy_direction: AccuracyDirection::LowerIsBetter, } } @@ -310,7 +310,7 @@ pub(crate) mod test_support { #[cfg(test)] mod tests { use super::*; - use test_support::{deployment, rqe, METRIC}; + use test_support::{deployment, raqe, METRIC}; fn labels(names: &[&str]) -> LabelSet { names.iter().map(|s| s.to_string()).collect() @@ -348,9 +348,9 @@ mod tests { ]), }, )]); - let r = Rqe { + let r = Raqe { grouping_labels: labels(&["service"]), - ..rqe(60, 60) + ..raqe(60, 60) }; assert_eq!(validate_facts(&[r], &facts), Ok(())); } @@ -365,17 +365,17 @@ mod tests { cardinality: BTreeMap::from([(labels(&["service"]), 0)]), }, )]); - let rqes = [ - Rqe { + let raqes = [ + Raqe { metric: "missing".into(), - ..rqe(60, 60) + ..raqe(60, 60) }, - Rqe { + Raqe { grouping_labels: labels(&["pod"]), - ..rqe(60, 60) + ..raqe(60, 60) }, ]; - let problems = validate_facts(&rqes, &facts).unwrap_err(); + let problems = validate_facts(&raqes, &facts).unwrap_err(); let has = |needle: &str| problems.iter().any(|p| p.contains(needle)); assert!(has("missing: no facts")); assert!(has("scrape interval is 0")); @@ -398,9 +398,9 @@ mod tests { ]), }, )]); - let r = Rqe { + let r = Raqe { grouping_labels: labels(&["service"]), - ..rqe(60, 60) + ..raqe(60, 60) }; let problems = validate_facts(&[r], &facts).unwrap_err(); assert!(problems[0].contains("60 groups but only 50 series")); diff --git a/rqe-optimizer/src/milp.rs b/rqe-optimizer/src/milp.rs index d26abd06..5dbb31ae 100644 --- a/rqe-optimizer/src/milp.rs +++ b/rqe-optimizer/src/milp.rs @@ -1,11 +1,11 @@ -//! MILP solver for one scalarized RQE deployment optimization (§4). +//! MILP solver for one scalarized RAQE deployment optimization (§4). //! //! The model is documented in `docs/rqe_sketch_deployment_v1.md`. It avoids //! enumerating the Cartesian product of eligible deployment choices. use crate::analytical_cost_model::{self, score, PhaseCost, PlanCost, BYTES_PER_GIB}; use crate::candidates::eligible_deployments_for; -use crate::{Deployment, Mapping, Rqe, WorkloadFacts}; +use crate::{Deployment, Mapping, Raqe, WorkloadFacts}; use good_lp::{ default_solver, variable, Expression, ProblemVariables, ResolutionError, Solution, SolverModel, Variable, @@ -45,8 +45,8 @@ impl Objective { } } -/// Optional hard bounds for a solve. An empty latency vector means no RQE has -/// a latency bound; otherwise it is index-aligned with `rqes`. +/// Optional hard bounds for a solve. An empty latency vector means no RAQE has +/// a latency bound; otherwise it is index-aligned with `raqes`. #[derive(Debug, Clone, Default)] pub struct MilpBounds { pub max_query_latency_secs: Vec>, @@ -63,7 +63,7 @@ pub struct MilpSolution { /// error; use `enumerate::unservable` to report them before calling this /// function. pub fn minimize( - rqes: &[Rqe], + raqes: &[Raqe], deployments: &[Deployment], facts: &WorkloadFacts, bounds: &MilpBounds, @@ -71,17 +71,17 @@ pub fn minimize( ) -> Result { assert!( bounds.max_query_latency_secs.is_empty() - || bounds.max_query_latency_secs.len() == rqes.len(), - "latency bounds must be empty or index-aligned with rqes" + || bounds.max_query_latency_secs.len() == raqes.len(), + "latency bounds must be empty or index-aligned with raqes" ); - let eligible: Vec> = rqes + let eligible: Vec> = raqes .iter() - .map(|rqe| eligible_deployments_for(rqe, deployments)) + .map(|raqe| eligible_deployments_for(raqe, deployments)) .collect(); assert!( eligible.iter().all(|choices| !choices.is_empty()), - "cannot solve a workload with an unservable RQE" + "cannot solve a workload with an unservable RAQE" ); let mut variables = ProblemVariables::new(); @@ -98,7 +98,7 @@ pub fn minimize( .collect() }) .collect(); - // Weighted storage cost per deployment: the max over the RQEs it serves. + // Weighted storage cost per deployment: the max over the RAQEs it serves. let deployment_storage_cost: Vec = deployments .iter() .map(|_| variables.add(variable().min(0))) @@ -110,33 +110,33 @@ pub fn minimize( .iter() .map(|deployment| objective.weigh(analytical_cost_model::ingest(deployment, facts))) .collect(); - let merge_and_query_cost = |rqe: &Rqe, deployment: &Deployment| { - objective.weigh(analytical_cost_model::merge(rqe, deployment, facts)) - + objective.weigh(analytical_cost_model::query(rqe, deployment, facts)) + let merge_and_query_cost = |raqe: &Raqe, deployment: &Deployment| { + objective.weigh(analytical_cost_model::merge(raqe, deployment, facts)) + + objective.weigh(analytical_cost_model::query(raqe, deployment, facts)) }; - let storage_cost_for_rqe_and_deployment = |rqe: &Rqe, deployment: &Deployment| { + let storage_cost_for_raqe_and_deployment = |raqe: &Raqe, deployment: &Deployment| { objective.weigh(PhaseCost { cpu_secs_per_sec: 0.0, - memory_bytes: analytical_cost_model::storage_bytes(rqe, deployment, facts), + memory_bytes: analytical_cost_model::storage_bytes(raqe, deployment, facts), }) }; // Real plans cost ~1e-6 CPU-sec/sec and read in microseconds, below // HiGHS's absolute gap (1e-6) and feasibility tolerance (1e-7). So costs - // are divided by `reference`, a plan's cost without sharing (each RQE on - // its cheapest deployment), and per-RQE bounds become exclusions. The + // are divided by `reference`, a plan's cost without sharing (each RAQE on + // its cheapest deployment), and per-RAQE bounds become exclusions. The // result is re-scored on real costs. - let reference: f64 = rqes + let reference: f64 = raqes .iter() .zip(&eligible) - .map(|(rqe, choices)| { + .map(|(raqe, choices)| { choices .iter() .map(|&deployment_index| { let deployment = &deployments[deployment_index]; ingest_cost[deployment_index] - + merge_and_query_cost(rqe, deployment) - + storage_cost_for_rqe_and_deployment(rqe, deployment) + + merge_and_query_cost(raqe, deployment) + + storage_cost_for_raqe_and_deployment(raqe, deployment) }) .fold(f64::INFINITY, f64::min) }) @@ -151,40 +151,41 @@ pub fn minimize( for (&cost, &active_variable) in ingest_cost.iter().zip(&active) { goal.add_mul(cost / reference, active_variable); } - for (rqe_index, choices) in assignments.iter().enumerate() { + for (raqe_index, choices) in assignments.iter().enumerate() { for &(deployment_index, assignment) in choices { goal.add_mul( - merge_and_query_cost(&rqes[rqe_index], &deployments[deployment_index]) / reference, + merge_and_query_cost(&raqes[raqe_index], &deployments[deployment_index]) + / reference, assignment, ); } } let mut model = variables.minimise(goal).using(default_solver); - for (rqe_index, choices) in assignments.iter().enumerate() { + for (raqe_index, choices) in assignments.iter().enumerate() { let assignment_sum: Expression = choices.iter().map(|(_, variable)| *variable).sum(); model.add_constraint(assignment_sum.eq(1)); for &(deployment_index, assignment) in choices { - let rqe = &rqes[rqe_index]; + let raqe = &raqes[raqe_index]; let deployment = &deployments[deployment_index]; model.add_constraint((assignment - active[deployment_index]).leq(0)); // Zero when `w_mem` is 0; such a row can never bind. let scaled_storage_cost = - storage_cost_for_rqe_and_deployment(rqe, deployment) / reference; + storage_cost_for_raqe_and_deployment(raqe, deployment) / reference; if scaled_storage_cost > 0.0 { model.add_constraint( (scaled_storage_cost * assignment - deployment_storage_cost[deployment_index]) .leq(0), ); } - // Each RQE takes exactly one deployment, so a per-RQE bound just + // Each RAQE takes exactly one deployment, so a per-RAQE bound just // forbids the choices over it. Comparing in f64 here, not in the // solver, keeps µs latencies clear of its feasibility tolerance. - let latency = analytical_cost_model::query_latency_secs(rqe, deployment, facts); + let latency = analytical_cost_model::query_latency_secs(raqe, deployment, facts); if bounds .max_query_latency_secs - .get(rqe_index) + .get(raqe_index) .copied() .flatten() .is_some_and(|limit| latency > limit) @@ -213,10 +214,10 @@ pub fn minimize( .find_map(|&(deployment_index, assignment)| { (solved.value(assignment) > 0.5).then_some(deployment_index) }) - .expect("MILP assigns exactly one deployment to every RQE") + .expect("MILP assigns exactly one deployment to every RAQE") }) .collect(); - let plan_cost = score(rqes, deployments, &mapping, facts); + let plan_cost = score(raqes, deployments, &mapping, facts); Ok(MilpSolution { mapping, plan_cost }) } @@ -231,10 +232,10 @@ mod tests { test_support::deployment(memory, insert_cpu_secs, 1.0, query_cpu_secs, 60, 60) } - fn rqe(id: &str, lookback_secs: u64) -> Rqe { - Rqe { + fn raqe(id: &str, lookback_secs: u64) -> Raqe { + Raqe { id: id.into(), - ..test_support::rqe(lookback_secs, 60) + ..test_support::raqe(lookback_secs, 60) } } @@ -243,15 +244,25 @@ mod tests { } /// The MILP's plan scores as well as the best of every mapping. - fn assert_matches_brute_force(rqes: &[Rqe], deployments: &[Deployment], objective: Objective) { + fn assert_matches_brute_force( + raqes: &[Raqe], + deployments: &[Deployment], + objective: Objective, + ) { let facts = facts(1, 1); - let best = brute_force(rqes, deployments) + let best = brute_force(raqes, deployments) .iter() - .map(|mapping| objective.value(&score(rqes, deployments, mapping, &facts))) + .map(|mapping| objective.value(&score(raqes, deployments, mapping, &facts))) .min_by(f64::total_cmp) .expect("test workload is servable"); - let milp = minimize(rqes, deployments, &facts, &MilpBounds::default(), objective) - .expect("feasible MILP"); + let milp = minimize( + raqes, + deployments, + &facts, + &MilpBounds::default(), + objective, + ) + .expect("feasible MILP"); let got = objective.value(&milp.plan_cost); assert!( (got - best).abs() <= 1e-9 * best, @@ -261,12 +272,12 @@ mod tests { #[test] fn minimizes_cpu_and_respects_a_latency_bound() { - let rqes = vec![rqe("r", 60)]; + let raqes = vec![raqe("r", 60)]; // CPU 1 + 3/60 with latency 3, or 2 + 1/60 with latency 1. let deployments = vec![deployment(1.0, 20.0, 3.0), deployment(2.0, 10.0, 1.0)]; let solve = |bounds: &MilpBounds| { minimize( - &rqes, + &raqes, &deployments, &facts(1, 1), bounds, @@ -285,20 +296,20 @@ mod tests { #[test] fn matches_brute_force_with_shared_deployment_costs() { - let rqes = vec![rqe("frequent", 60), rqe("long", 600)]; + let raqes = vec![raqe("frequent", 60), raqe("long", 600)]; let deployments = vec![ deployment(1.0, 0.5 * BYTES_PER_GIB, 400.0), deployment(3.0, 0.1 * BYTES_PER_GIB, 1.0), deployment(0.2, 2.0 * BYTES_PER_GIB, 1.0), ]; for objective in [weights(1.0, 0.0), weights(0.0, 1.0), weights(1.0, 4.0)] { - assert_matches_brute_force(&rqes, &deployments, objective); + assert_matches_brute_force(&raqes, &deployments, objective); } } #[test] fn memory_weight_trades_cpu_for_memory() { - let rqes = vec![rqe("r", 60)]; + let raqes = vec![raqe("r", 60)]; // CPU-heavy and small, or CPU-light and twice the size. let deployments = vec![ deployment(1.0, 1.0 * BYTES_PER_GIB, 0.0), @@ -306,7 +317,7 @@ mod tests { ]; let solve = |objective| { minimize( - &rqes, + &raqes, &deployments, &facts(1, 1), &MilpBounds::default(), @@ -324,7 +335,7 @@ mod tests { fn tiny_magnitudes_match_brute_force_and_respect_latency_bounds() { // Real plans cost ~1e-6 CPU-sec/sec with µs latencies: below HiGHS's // absolute gap and feasibility tolerances unless the model is scaled. - let rqes = vec![rqe("frequent", 60), rqe("long", 600)]; + let raqes = vec![raqe("frequent", 60), raqe("long", 600)]; let tiny = 1e-9; let deployments = vec![ deployment(1.0 * tiny, 0.5 * tiny * BYTES_PER_GIB, 400.0 * tiny), @@ -338,18 +349,18 @@ mod tests { }) .collect::>(); for objective in [weights(1.0, 0.0), weights(1.0, 4.0)] { - assert_matches_brute_force(&rqes, &deployments, objective); + assert_matches_brute_force(&raqes, &deployments, objective); } // The cheap deployment's latency is 1.5e-7 s, over a 1e-7 s bound by // less than HiGHS's absolute feasibility tolerance. - let rqes = vec![rqe("r", 60)]; + let raqes = vec![raqe("r", 60)]; let deployments = vec![ deployment(tiny, tiny, 1.5e-7), deployment(10.0 * tiny, tiny, 0.5e-7), ]; let milp = minimize( - &rqes, + &raqes, &deployments, &facts(1, 1), &MilpBounds { diff --git a/rqe-optimizer/src/pareto.rs b/rqe-optimizer/src/pareto.rs index 66d9e0e1..04c78615 100644 --- a/rqe-optimizer/src/pareto.rs +++ b/rqe-optimizer/src/pareto.rs @@ -1,4 +1,4 @@ -//! Non-dominated filtering for `(CPU, memory, per-RQE latency)`. +//! Non-dominated filtering for `(CPU, memory, per-RAQE latency)`. use crate::{analytical_cost_model::PlanCost, Mapping}; From 9bf676346071d3bd2cd23a56cb32be89b0ea54e0 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 11:53:51 -0400 Subject: [PATCH 2/7] rqe-optimizer: time inputs in milliseconds Lookback, interval, window, slide and scrape interval are Millis. Rates (CPU-sec/sec, samples/sec) stay per second via secs(). Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/examples/small_problem.rs | 46 ++++----- rqe-optimizer/src/analytical_cost_model.rs | 32 ++++-- rqe-optimizer/src/autosketch.rs | 48 ++++----- rqe-optimizer/src/candidates.rs | 114 ++++++++++----------- rqe-optimizer/src/enumerate.rs | 12 +-- rqe-optimizer/src/lib.rs | 79 +++++++------- rqe-optimizer/src/milp.rs | 16 +-- 7 files changed, 181 insertions(+), 166 deletions(-) diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index 2fb3949a..a368b19f 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -81,7 +81,7 @@ fn load_cost_table() -> AtomicCostTable { fn facts() -> WorkloadFacts { let http_metric_facts = || MetricFacts { labels: label_set(&["service", "endpoint", "pod"]), - scrape_interval_secs: 15, + scrape_interval_ms: 15_000, cardinality: [ (label_set(&["service"]), 5), (label_set(&["service", "endpoint"]), 50), @@ -105,8 +105,8 @@ fn raqes() -> Vec { Raqe { id: "req_rate_1h".to_string(), capability: Capability::RateOrIncrease, - lookback_secs: 3_600, - interval_secs: 60, + lookback_ms: 3_600_000, + interval_ms: 60_000, metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), @@ -116,8 +116,8 @@ fn raqes() -> Vec { Raqe { id: "req_rate_1d".to_string(), capability: Capability::RateOrIncrease, - lookback_secs: 86_400, - interval_secs: 60, + lookback_ms: 86_400_000, + interval_ms: 60_000, metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), @@ -127,8 +127,8 @@ fn raqes() -> Vec { Raqe { id: "req_rate_5m_tick".to_string(), capability: Capability::RateOrIncrease, - lookback_secs: 3_600, - interval_secs: 300, + lookback_ms: 3_600_000, + interval_ms: 300_000, metric: requests(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), @@ -138,8 +138,8 @@ fn raqes() -> Vec { Raqe { id: "latency_p99_1h".to_string(), capability: Capability::Quantile, - lookback_secs: 3_600, - interval_secs: 60, + lookback_ms: 3_600_000, + interval_ms: 60_000, metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), @@ -149,8 +149,8 @@ fn raqes() -> Vec { Raqe { id: "latency_p99_6h_tick".to_string(), capability: Capability::Quantile, - lookback_secs: 21_600, - interval_secs: 300, + lookback_ms: 21_600_000, + interval_ms: 300_000, metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), @@ -160,8 +160,8 @@ fn raqes() -> Vec { Raqe { id: "latency_p99_1d".to_string(), capability: Capability::Quantile, - lookback_secs: 86_400, - interval_secs: 60, + lookback_ms: 86_400_000, + interval_ms: 60_000, metric: duration(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), @@ -171,8 +171,8 @@ fn raqes() -> Vec { Raqe { id: "distinct_services_1h".to_string(), capability: Capability::Cardinality, - lookback_secs: 3_600, - interval_secs: 60, + lookback_ms: 3_600_000, + interval_ms: 60_000, metric: requests(), grouping_labels: s.clone(), accuracy_metric: CARDINALITY_ERR.to_string(), @@ -186,8 +186,8 @@ fn raqes() -> Vec { Raqe { id: "top_endpoints_1h".to_string(), capability: Capability::TopK, - lookback_secs: 3_600, - interval_secs: 60, + lookback_ms: 3_600_000, + interval_ms: 60_000, metric: requests(), grouping_labels: se.clone(), accuracy_metric: TOPK_PRECISION.to_string(), @@ -221,25 +221,25 @@ fn print_mapping( for (raqe, &deployment_index) in raqes.iter().zip(mapping) { let deployment = &deployments[deployment_index]; println!( - " {} -> {} {} (x={}s, y={}s)", + " {} -> {} {} (x={}ms, y={}ms)", raqe.id, deployment.config.sketch, deployment.config.sketch_config, - deployment.window_secs, - deployment.slide_secs, + deployment.window_ms, + deployment.slide_ms, ); } } fn print_candidate(candidate_number: usize, deployment: &rqe_optimizer::Deployment) { println!( - "candidate {candidate_number}: {} {} {} by {:?} (x={}s, y={}s)", + "candidate {candidate_number}: {} {} {} by {:?} (x={}ms, y={}ms)", deployment.config.sketch, deployment.config.sketch_config, deployment.metric, deployment.grouping_labels, - deployment.window_secs, - deployment.slide_secs, + deployment.window_ms, + deployment.slide_ms, ); } diff --git a/rqe-optimizer/src/analytical_cost_model.rs b/rqe-optimizer/src/analytical_cost_model.rs index bec19aa4..33820544 100644 --- a/rqe-optimizer/src/analytical_cost_model.rs +++ b/rqe-optimizer/src/analytical_cost_model.rs @@ -15,7 +15,7 @@ // ponytail: a family with one fixed-size instance for all groups (HydraKLL, // sketch-bench#142) needs a per-family shape so it isn't multiplied by card(G). -use crate::{Capability, Deployment, Mapping, Raqe, WorkloadFacts}; +use crate::{secs, Capability, Deployment, Mapping, Raqe, WorkloadFacts}; use std::collections::{BTreeMap, BTreeSet}; pub(crate) const BYTES_PER_GIB: f64 = (1u64 << 30) as f64; @@ -90,7 +90,7 @@ fn open_window_count(deployment: &Deployment) -> f64 { /// `L / x`: windows merged per query. fn merged_window_count(raqe: &Raqe, deployment: &Deployment) -> f64 { deployment - .query_instance_count(raqe.lookback_secs) + .query_instance_count(raqe.lookback_ms) .expect("only eligible pairs are costed") as f64 } @@ -115,7 +115,7 @@ pub(crate) fn merge(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) let merges_per_group = merged_window_count(raqe, deployment) - 1.0; PhaseCost { cpu_secs_per_sec: groups * merges_per_group * deployment.config.merge_cpu_secs - / raqe.interval_secs as f64, + / secs(raqe.interval_ms), memory_bytes: groups * merge_memory_per_group(raqe, deployment), } } @@ -137,7 +137,7 @@ pub(crate) fn query(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) _ => OUTPUT_BYTES_PER_VALUE, }; PhaseCost { - cpu_secs_per_sec: groups * deployment.config.query_cpu_secs / raqe.interval_secs as f64, + cpu_secs_per_sec: groups * deployment.config.query_cpu_secs / secs(raqe.interval_ms), memory_bytes: groups * output_bytes_per_group, } } @@ -145,7 +145,7 @@ pub(crate) fn query(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) /// Closed windows kept to answer `raqe`: `card(G) · m · ((L − x)/y + 1)`. pub(crate) fn storage_bytes(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) -> f64 { let closed_windows = deployment - .closed_instance_count(raqe.lookback_secs) + .closed_instance_count(raqe.lookback_ms) .expect("only eligible pairs are costed") as f64; group_count(deployment, facts) * deployment.config.mem_bytes_per_instance * closed_windows } @@ -218,8 +218,13 @@ mod tests { // 4 groups and 7 samples/sec. Window 20 s sliding by 10 s: 2 open // windows. Lookback 60 s every 30 s: 3 windows merged, and // (60 − 20)/10 + 1 = 5 closed windows stored. - let deployment = deployment(10.0, 2.0, 3.0, 5.0, 20, 10); - let result = score(&[raqe(60, 30)], &[deployment], &vec![0], &facts(4, 7)); + let deployment = deployment(10.0, 2.0, 3.0, 5.0, 20_000, 10_000); + let result = score( + &[raqe(60_000, 30_000)], + &[deployment], + &vec![0], + &facts(4, 7), + ); assert_eq!(result.ingest.cpu_secs_per_sec, 28.0); // 7 × 2 × 2 assert_eq!(result.ingest.memory_bytes, 80.0); // 4 × 10 × 2 @@ -236,16 +241,21 @@ mod tests { #[test] fn a_direct_query_stores_one_closed_window_and_never_merges() { - let deployment = deployment(10.0, 1.0, 1.0, 1.0, 60, 60); - let result = score(&[raqe(60, 60)], &[deployment], &vec![0], &facts(1, 1)); + let deployment = deployment(10.0, 1.0, 1.0, 1.0, 60_000, 60_000); + let result = score( + &[raqe(60_000, 60_000)], + &[deployment], + &vec![0], + &facts(1, 1), + ); assert_eq!(result.storage.memory_bytes, 10.0); assert_eq!(result.merge, PhaseCost::default()); } #[test] fn shared_deployment_stores_for_its_longest_lookback() { - let deployment = deployment(10.0, 1.0, 1.0, 1.0, 10, 10); - let raqes = [raqe(60, 10), raqe(600, 10)]; + let deployment = deployment(10.0, 1.0, 1.0, 1.0, 10_000, 10_000); + let raqes = [raqe(60_000, 10_000), raqe(600_000, 10_000)]; let result = score(&raqes, &[deployment], &vec![0, 0], &facts(2, 2)); // 2 groups × 10 bytes × ((600 − 10)/10 + 1) closed windows. assert_eq!(result.storage.memory_bytes, 1200.0); diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index 48698047..8be8a382 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -14,7 +14,7 @@ //! from the same sketches as the MILP. use crate::candidates::gcd; -use crate::{AtomicCostEntry, Deployment, Mapping, Raqe, Seconds}; +use crate::{AtomicCostEntry, Deployment, Mapping, Millis, Raqe}; use std::collections::{BTreeMap, VecDeque}; use std::time::Instant; @@ -40,11 +40,8 @@ pub struct AutoSketchPlan { /// One sliding sketch per query window: `x = S`, `y = T`, or `gcd(S, T)` /// when `T` does not divide `S`, so the window still tiles into slides. -pub fn window_adapter(raqe: &Raqe) -> (Seconds, Seconds) { - ( - raqe.lookback_secs, - gcd(raqe.lookback_secs, raqe.interval_secs), - ) +pub fn window_adapter(raqe: &Raqe) -> (Millis, Millis) { + (raqe.lookback_ms, gcd(raqe.lookback_ms, raqe.interval_ms)) } /// Search every RAQE independently. `accuracy` is the benchmark oracle: the @@ -72,14 +69,14 @@ pub fn plan( .iter() .zip(&searches) .map(|(raqe, s)| { - let (window_secs, slide_secs) = window_adapter(raqe); + let (window_ms, slide_ms) = window_adapter(raqe); Deployment { capability: raqe.capability, metric: raqe.metric.clone(), grouping_labels: raqe.grouping_labels.clone(), config: costs[s.selected.expect("unservable RAQEs returned above")].clone(), - window_secs, - slide_secs, + window_ms, + slide_ms, } }) .collect(); @@ -354,12 +351,12 @@ mod tests { .collect() } - fn raqe(id: &str, lookback: Seconds, interval: Seconds, tolerance: f64) -> Raqe { + fn raqe(id: &str, lookback: Millis, interval: Millis, tolerance: f64) -> Raqe { Raqe { id: id.into(), capability: Capability::TopK, - lookback_secs: lookback, - interval_secs: interval, + lookback_ms: lookback, + interval_ms: interval, metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: ERR.into(), @@ -384,7 +381,7 @@ mod tests { let costs = grid(&[1, 2, 3], &[100, 200, 400, 800]); // Needs rows * cols >= 1000: rows=3 cols=400 (1200 counters) is the // smallest; rows=2 cols=800 and rows=3 cols=800 also pass. - let r = raqe("r", 3_600, 60, 1.0 / 1_000.0); + let r = raqe("r", 3_600_000, 60_000, 1.0 / 1_000.0); let found = search(&r, &costs, 7, table_accuracy); assert_eq!(found.selected, Some(cheapest_feasible(&r, &costs))); } @@ -394,7 +391,7 @@ mod tests { let rows: Vec = (1..=8).collect(); let cols: Vec = (0..8).map(|i| 64 << i).collect(); let costs = grid(&rows, &cols); - let r = raqe("r", 3_600, 60, 1.0 / 2_000.0); + let r = raqe("r", 3_600_000, 60_000, 1.0 / 2_000.0); let found = search(&r, &costs, 42, table_accuracy); assert!(found.selected.is_some()); assert!( @@ -409,7 +406,10 @@ mod tests { #[test] fn never_shares_identical_choices() { let costs = grid(&[2, 3], &[256, 512]); - let raqes = vec![raqe("a", 3_600, 60, 0.01), raqe("b", 3_600, 60, 0.01)]; + let raqes = vec![ + raqe("a", 3_600_000, 60_000, 0.01), + raqe("b", 3_600_000, 60_000, 0.01), + ]; let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); assert_eq!(plan.deployments.len(), 2); assert_eq!(plan.deployments[0], plan.deployments[1]); @@ -432,12 +432,12 @@ mod tests { let costs = grid(&[2, 3], &[256, 512]); // T divides S, and T does not divide S (gcd slide). let raqes = vec![ - raqe("tiled", 3_600, 60, 0.01), - raqe("gcd", 3_600, 280, 0.01), + raqe("tiled", 3_600_000, 60_000, 0.01), + raqe("gcd", 3_600_000, 280_000, 0.01), ]; let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); - assert_eq!(window_adapter(&raqes[0]), (3_600, 60)); - assert_eq!(window_adapter(&raqes[1]), (3_600, 40)); + assert_eq!(window_adapter(&raqes[0]), (3_600_000, 60_000)); + assert_eq!(window_adapter(&raqes[1]), (3_600_000, 40_000)); for (r, d) in raqes.iter().zip(&plan.deployments) { assert!(is_eligible(r, d), "{} not eligible", r.id); } @@ -447,8 +447,8 @@ mod tests { fn reports_unservable_raqe() { let costs = grid(&[2, 3], &[256, 512]); let raqes = vec![ - raqe("ok", 3_600, 60, 0.01), - raqe("too_strict", 3_600, 60, 1e-9), + raqe("ok", 3_600_000, 60_000, 0.01), + raqe("too_strict", 3_600_000, 60_000, 1e-9), ]; assert_eq!( plan(&raqes, &costs, 1, table_accuracy).unwrap_err(), @@ -459,7 +459,7 @@ mod tests { #[test] fn missing_measurement_is_infeasible() { let costs = grid(&[2, 3], &[256, 512]); - let r = raqe("r", 3_600, 60, 0.01); + let r = raqe("r", 3_600_000, 60_000, 0.01); let found = search(&r, &costs, 1, |_, _| None); assert_eq!(found.selected, None); assert_eq!(found.probes.len(), costs.len()); @@ -480,7 +480,7 @@ mod tests { fn quantile_raqe(tolerance: f64) -> Raqe { Raqe { capability: Capability::Quantile, - ..raqe("q", 3_600, 60, tolerance) + ..raqe("q", 3_600_000, 60_000, tolerance) } } @@ -512,7 +512,7 @@ mod tests { cs.sketch = "countsketch-heap-topk-fastpath-vector2d".into(); cs.mem_bytes_per_instance = 100.0; costs.push(cs); - let r = raqe("r", 3_600, 60, 0.01); + let r = raqe("r", 3_600_000, 60_000, 0.01); let found = search(&r, &costs, 3, table_accuracy); assert_eq!(found.selected, Some(costs.len() - 1)); } diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index 67f52fbd..cd57258b 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -1,10 +1,10 @@ //! Candidate generation and eligibility (§3). use crate::analytical_cost_model::merge_memory_per_group; -use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Raqe, Seconds, WorkloadFacts}; +use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Millis, Raqe, WorkloadFacts}; use std::collections::{BTreeMap, BTreeSet}; -pub fn gcd(a: Seconds, b: Seconds) -> Seconds { +pub fn gcd(a: Millis, b: Millis) -> Millis { if b == 0 { a } else { @@ -12,7 +12,7 @@ pub fn gcd(a: Seconds, b: Seconds) -> Seconds { } } -fn divisors(value: Seconds) -> Vec { +fn divisors(value: Millis) -> Vec { let mut out = Vec::new(); let mut factor = 1; while factor <= value / factor { @@ -31,7 +31,7 @@ fn divisors(value: Seconds) -> Vec { /// The gcd of every non-empty subset of `values`, in one pass without listing /// subsets: `gcd(A ∪ {v}) = gcd(gcd(A), v)`, so each new value extends every /// gcd found so far. -fn subset_gcds(values: impl IntoIterator) -> BTreeSet { +fn subset_gcds(values: impl IntoIterator) -> BTreeSet { let mut gcds = BTreeSet::new(); for value in values { let extended: Vec<_> = gcds.iter().map(|&found| gcd(found, value)).collect(); @@ -46,7 +46,7 @@ fn subset_gcds(values: impl IntoIterator) -> BTreeSet { fn candidate_deployments( group: &[&Raqe], costs: &[AtomicCostEntry], - scrape_interval_secs: Seconds, + scrape_interval_ms: Millis, ) -> Vec { if group.is_empty() { return Vec::new(); @@ -54,33 +54,33 @@ fn candidate_deployments( let capability = group[0].capability; let windows = group .iter() - .flat_map(|r| divisors(r.lookback_secs)) - .filter(|window_secs| window_secs.is_multiple_of(scrape_interval_secs)) + .flat_map(|r| divisors(r.lookback_ms)) + .filter(|window_ms| window_ms.is_multiple_of(scrape_interval_ms)) .collect::>(); let mut deployments = Vec::new(); - for window_secs in windows { + for window_ms in windows { // gcd(x, T) is the largest slide that serves an RAQE. For RAQEs sharing a // deployment, the gcd of theirs is strictly better than any finer // slide that serves them all. Any subset might share. let slides = subset_gcds( group .iter() - .filter(|r| r.lookback_secs % window_secs == 0) - .map(|r| gcd(window_secs, r.interval_secs)), + .filter(|r| r.lookback_ms % window_ms == 0) + .map(|r| gcd(window_ms, r.interval_ms)), ); for config in costs .iter() .filter(|c| capability.families().contains(&c.sketch.as_str())) { - for &slide_secs in &slides { - if slide_secs.is_multiple_of(scrape_interval_secs) { + for &slide_ms in &slides { + if slide_ms.is_multiple_of(scrape_interval_ms) { deployments.push(Deployment { capability, metric: group[0].metric.clone(), grouping_labels: group[0].grouping_labels.clone(), config: config.clone(), - window_secs, - slide_secs, + window_ms, + slide_ms, }); } } @@ -116,7 +116,7 @@ pub fn build_all_candidates_unpruned( groups .iter() .flat_map(|(&(_, metric, _), group)| { - candidate_deployments(group, costs, facts[metric].scrape_interval_secs) + candidate_deployments(group, costs, facts[metric].scrape_interval_ms) }) .collect() } @@ -255,7 +255,7 @@ fn ingest_memory(deployment: &Deployment) -> f64 { fn query_latency(deployment: &Deployment, raqe: &Raqe) -> f64 { let instances = deployment - .query_instance_count(raqe.lookback_secs) + .query_instance_count(raqe.lookback_ms) .expect("candidate coverage only contains exactly tiled RAQEs"); deployment.config.query_cpu_secs + instances.saturating_sub(1) as f64 * deployment.config.merge_cpu_secs @@ -264,7 +264,7 @@ fn query_latency(deployment: &Deployment, raqe: &Raqe) -> f64 { /// Closed-window bytes per group when serving `raqe`. fn stored_memory(deployment: &Deployment, raqe: &Raqe) -> f64 { deployment - .closed_instance_count(raqe.lookback_secs) + .closed_instance_count(raqe.lookback_ms) .expect("candidate coverage only contains exactly tiled RAQEs") as f64 * deployment.config.mem_bytes_per_instance } @@ -282,11 +282,11 @@ pub fn is_eligible(r: &Raqe, d: &Deployment) -> bool { r.capability == d.capability && r.metric == d.metric && r.grouping_labels == d.grouping_labels - && d.window_secs != 0 - && d.slide_secs != 0 - && d.window_secs.is_multiple_of(d.slide_secs) - && r.lookback_secs.is_multiple_of(d.window_secs) - && r.interval_secs.is_multiple_of(d.slide_secs) + && d.window_ms != 0 + && d.slide_ms != 0 + && d.window_ms.is_multiple_of(d.slide_ms) + && r.lookback_ms.is_multiple_of(d.window_ms) + && r.interval_ms.is_multiple_of(d.slide_ms) && r.accuracy_ok_for(&d.config) } @@ -296,12 +296,12 @@ mod tests { use crate::test_support::{facts, METRIC}; use crate::{AccuracyDirection, LabelSet}; use std::collections::BTreeMap; - fn raqe(id: &str, lookback: Seconds, interval: Seconds) -> Raqe { + fn raqe(id: &str, lookback: Millis, interval: Millis) -> Raqe { Raqe { id: id.into(), capability: Capability::TopK, - lookback_secs: lookback, - interval_secs: interval, + lookback_ms: lookback, + interval_ms: interval, metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), @@ -322,32 +322,32 @@ mod tests { } #[test] fn shared_slide_comes_from_subset_gcd_not_all_divisors() { - let raqes = vec![raqe("a", 60, 20), raqe("b", 60, 30)]; + let raqes = vec![raqe("a", 60_000, 20_000), raqe("b", 60_000, 30_000)]; let candidates = build_all_candidates(&raqes, &[cost()], &facts(1, 1)); let slides: BTreeSet<_> = candidates .iter() - .filter(|d| d.window_secs == 60) - .map(|d| d.slide_secs) + .filter(|d| d.window_ms == 60_000) + .map(|d| d.slide_ms) .collect(); - assert_eq!(slides, BTreeSet::from([10, 20, 30])); + assert_eq!(slides, BTreeSet::from([10_000, 20_000, 30_000])); } #[test] fn windows_and_slides_are_multiples_of_the_scrape_interval() { - let raqes = vec![raqe("a", 60, 20), raqe("b", 60, 30)]; + let raqes = vec![raqe("a", 60_000, 20_000), raqe("b", 60_000, 30_000)]; let mut facts = facts(1, 1); - facts.get_mut(METRIC).unwrap().scrape_interval_secs = 15; + facts.get_mut(METRIC).unwrap().scrape_interval_ms = 15_000; let candidates = build_all_candidates_unpruned(&raqes, &[cost()], &facts); assert!(!candidates.is_empty()); // Without the filter, windows 1..60 and slides 10/20 would appear. assert!(candidates .iter() - .all(|d| d.window_secs % 15 == 0 && d.slide_secs % 15 == 0)); + .all(|d| d.window_ms % 15_000 == 0 && d.slide_ms % 15_000 == 0)); } #[test] fn a_min_query_is_only_offered_the_min_accumulator() { let r = Raqe { capability: Capability::Min, - ..raqe("r", 60, 60) + ..raqe("r", 60_000, 60_000) }; let named = |sketch: &str| AtomicCostEntry { sketch: sketch.into(), @@ -364,27 +364,27 @@ mod tests { #[test] fn eligibility_requires_exact_non_overlapping_tiling() { - let r = raqe("r", 600, 180); + let r = raqe("r", 600_000, 180_000); let d = Deployment { capability: Capability::TopK, metric: METRIC.into(), grouping_labels: LabelSet::new(), config: cost(), - window_secs: 120, - slide_secs: 60, + window_ms: 120_000, + slide_ms: 60_000, }; assert!(is_eligible(&r, &d)); assert!(!is_eligible( &r, &Deployment { - window_secs: 128, + window_ms: 128_000, ..d.clone() } )); assert!(!is_eligible( &r, &Deployment { - slide_secs: 70, + slide_ms: 70_000, ..d } )); @@ -392,17 +392,17 @@ mod tests { #[test] fn prunes_finer_slide_when_the_coarser_slide_serves_the_same_raqe() { - let r = raqe("r", 60, 60); + let r = raqe("r", 60_000, 60_000); let coarse = Deployment { capability: Capability::TopK, metric: METRIC.into(), grouping_labels: LabelSet::new(), config: cost(), - window_secs: 60, - slide_secs: 60, + window_ms: 60_000, + slide_ms: 60_000, }; let fine = Deployment { - slide_secs: 30, + slide_ms: 30_000, ..coarse.clone() }; @@ -413,19 +413,19 @@ mod tests { #[test] fn retains_candidate_with_lower_query_latency() { - let r = raqe("r", 60, 60); + let r = raqe("r", 60_000, 60_000); let cost = cost(); let large_window = Deployment { capability: Capability::TopK, metric: METRIC.into(), grouping_labels: LabelSet::new(), config: cost.clone(), - window_secs: 60, - slide_secs: 60, + window_ms: 60_000, + slide_ms: 60_000, }; let small_window = Deployment { - window_secs: 30, - slide_secs: 30, + window_ms: 30_000, + slide_ms: 30_000, ..large_window.clone() }; @@ -437,7 +437,7 @@ mod tests { #[test] fn retains_candidate_with_lower_stored_memory() { - let r = raqe("r", 60, 60); + let r = raqe("r", 60_000, 60_000); // Stores (60 − 60) / 60 + 1 = 1 closed instance of 10 bytes. let whole_window = Deployment { capability: Capability::TopK, @@ -447,8 +447,8 @@ mod tests { mem_bytes_per_instance: 10.0, ..cost() }, - window_secs: 60, - slide_secs: 60, + window_ms: 60_000, + slide_ms: 60_000, }; // Smaller, faster sketches, but (60 − 20) / 20 + 1 = 3 of them: 18 bytes. let panes = Deployment { @@ -458,8 +458,8 @@ mod tests { merge_cpu_secs: 0.1, ..cost() }, - window_secs: 20, - slide_secs: 20, + window_ms: 20_000, + slide_ms: 20_000, ..whole_window.clone() }; @@ -470,7 +470,7 @@ mod tests { #[test] fn retains_direct_query_candidate_that_never_merges() { - let r = raqe("r", 60, 60); + let r = raqe("r", 60_000, 60_000); // x == L: no merge, so no merge memory. let direct = Deployment { capability: Capability::TopK, @@ -480,8 +480,8 @@ mod tests { mem_bytes_per_instance: 10.0, ..cost() }, - window_secs: 60, - slide_secs: 60, + window_ms: 60_000, + slide_ms: 60_000, }; // Smaller and cheaper on every other cost, but merges two windows, // holding a 4-byte accumulator per group. @@ -492,8 +492,8 @@ mod tests { merge_cpu_secs: 0.1, ..cost() }, - window_secs: 30, - slide_secs: 30, + window_ms: 30_000, + slide_ms: 30_000, ..direct.clone() }; diff --git a/rqe-optimizer/src/enumerate.rs b/rqe-optimizer/src/enumerate.rs index ad758dfe..f2aea70b 100644 --- a/rqe-optimizer/src/enumerate.rs +++ b/rqe-optimizer/src/enumerate.rs @@ -137,8 +137,8 @@ mod tests { Raqe { id: id.to_string(), capability: Capability::TopK, - lookback_secs: lookback, - interval_secs: interval, + lookback_ms: lookback, + interval_ms: interval, metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".to_string(), @@ -149,7 +149,7 @@ mod tests { #[test] fn two_raqes_can_share_one_deployment() { - let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; + let raqes = vec![raqe("a", 60_000, 3_600_000), raqe("b", 60_000, 3_600_000)]; let table = vec![cost("cms-heap-topk-fastpath-vector2d")]; let deployments = build_all_candidates(&raqes, &table); @@ -163,7 +163,7 @@ mod tests { #[test] fn unservable_raqe_yields_no_mappings() { - let raqes = vec![raqe("a", 60, 3_600)]; + let raqes = vec![raqe("a", 60_000, 3_600_000)]; let table: Vec = Vec::new(); // no configs at all let deployments = build_all_candidates(&raqes, &table); @@ -173,7 +173,7 @@ mod tests { #[test] fn streaming_visits_the_same_mappings_as_eager_enumeration() { - let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; + let raqes = vec![raqe("a", 60_000, 3_600_000), raqe("b", 60_000, 3_600_000)]; let deployments = build_all_candidates(&raqes, &[cost("cms-heap-topk-fastpath-vector2d")]); let eager = brute_force(&raqes, &deployments); let mut streamed = Vec::new(); @@ -186,7 +186,7 @@ mod tests { #[test] fn streaming_can_stop_after_a_fixed_number_of_mappings() { - let raqes = vec![raqe("a", 60, 3_600), raqe("b", 60, 3_600)]; + let raqes = vec![raqe("a", 60_000, 3_600_000), raqe("b", 60_000, 3_600_000)]; let deployments = build_all_candidates(&raqes, &[cost("cms-heap-topk-fastpath-vector2d")]); let mut sampled = Vec::new(); let result = for_each_mapping_while(&raqes, &deployments, |mapping| { diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 42796b8a..12eb7253 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -21,8 +21,13 @@ use std::collections::BTreeSet; pub use aqpbm_core::{AtomicCostEntry, AtomicCostTable}; -/// Whole seconds. Window alignment is expressed with exact `%` checks. -pub type Seconds = u64; +/// Whole milliseconds. Window alignment is expressed with exact `%` checks. +pub type Millis = u64; + +/// Rates (CPU-sec/sec, samples/sec) stay per second. +pub(crate) fn secs(ms: Millis) -> f64 { + ms as f64 / 1000.0 +} /// A group-by key, compared as a set (§3's `labels_i == labels_D` rule). pub type LabelSet = BTreeSet; @@ -78,7 +83,7 @@ pub struct MetricFacts { /// Every label the metric's series carry. pub labels: LabelSet, /// Each series yields one sample per scrape. - pub scrape_interval_secs: Seconds, + pub scrape_interval_ms: Millis, /// `card(X)`: distinct value combinations of each label set `X` in use. /// Must include `labels` itself, whose cardinality is the series count. pub cardinality: BTreeMap, @@ -87,7 +92,7 @@ pub struct MetricFacts { impl MetricFacts { /// `λ`: samples/sec across all of the metric's series. pub fn arrival_rate_per_sec(&self) -> f64 { - self.cardinality[&self.labels] as f64 / self.scrape_interval_secs as f64 + self.cardinality[&self.labels] as f64 / secs(self.scrape_interval_ms) } } @@ -106,7 +111,7 @@ pub fn validate_facts(raqes: &[Raqe], facts: &WorkloadFacts) -> Result<(), Vec Option { - if self.slide_secs == 0 || !self.window_secs.is_multiple_of(self.slide_secs) { + if self.slide_ms == 0 || !self.window_ms.is_multiple_of(self.slide_ms) { return None; } - Some(self.window_secs / self.slide_secs) + Some(self.window_ms / self.slide_ms) } /// Number of non-overlapping instances merged for one query window. - pub fn query_instance_count(&self, lookback_secs: Seconds) -> Option { - if self.window_secs == 0 || !lookback_secs.is_multiple_of(self.window_secs) { + pub fn query_instance_count(&self, lookback_ms: Millis) -> Option { + if self.window_ms == 0 || !lookback_ms.is_multiple_of(self.window_ms) { return None; } - Some(lookback_secs / self.window_secs) + Some(lookback_ms / self.window_ms) } /// Closed instances kept to serve a lookback: those wholly inside it, /// which start in `[now − L, now − x]`, so `(L − x) / y + 1`. - pub fn closed_instance_count(&self, lookback_secs: Seconds) -> Option { - self.query_instance_count(lookback_secs)?; + pub fn closed_instance_count(&self, lookback_ms: Millis) -> Option { + self.query_instance_count(lookback_ms)?; self.active_instance_count()?; - Some((lookback_secs - self.window_secs) / self.slide_secs + 1) + Some((lookback_ms - self.window_ms) / self.slide_ms + 1) } } @@ -259,18 +264,18 @@ pub(crate) mod test_support { METRIC.to_string(), MetricFacts { labels: labels.clone(), - scrape_interval_secs: 1, + scrape_interval_ms: 1_000, cardinality: BTreeMap::from([(LabelSet::new(), groups), (labels, series)]), }, )]) } - pub fn raqe(lookback_secs: Seconds, interval_secs: Seconds) -> Raqe { + pub fn raqe(lookback_ms: Millis, interval_ms: Millis) -> Raqe { Raqe { id: "r".into(), capability: Capability::TopK, - lookback_secs, - interval_secs, + lookback_ms, + interval_ms, metric: METRIC.into(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), @@ -285,8 +290,8 @@ pub(crate) mod test_support { insert: f64, merge: f64, query: f64, - window_secs: Seconds, - slide_secs: Seconds, + window_ms: Millis, + slide_ms: Millis, ) -> Deployment { Deployment { capability: Capability::TopK, @@ -301,8 +306,8 @@ pub(crate) mod test_support { query_cpu_secs: query, query_accuracy: BTreeMap::from([("err".into(), 0.0)]), }, - window_secs, - slide_secs, + window_ms, + slide_ms, } } } @@ -320,16 +325,16 @@ mod tests { fn closed_instances_lie_wholly_inside_the_lookback() { // 10-minute windows every minute, over an hour: starts in minutes // [now − 60, now − 10]. - let sliding = deployment(1.0, 1.0, 1.0, 1.0, 600, 60); - assert_eq!(sliding.closed_instance_count(3_600), Some(51)); - assert_eq!(sliding.closed_instance_count(600), Some(1)); - assert_eq!(sliding.closed_instance_count(900), None); // not tiled by x + let sliding = deployment(1.0, 1.0, 1.0, 1.0, 600_000, 60_000); + assert_eq!(sliding.closed_instance_count(3_600_000), Some(51)); + assert_eq!(sliding.closed_instance_count(600_000), Some(1)); + assert_eq!(sliding.closed_instance_count(900_000), None); // not tiled by x } #[test] fn arrival_rate_is_series_over_scrape_interval() { let metric_facts = MetricFacts { - scrape_interval_secs: 15, + scrape_interval_ms: 15_000, ..test_support::facts(4, 600)[METRIC].clone() }; assert_eq!(metric_facts.arrival_rate_per_sec(), 40.0); @@ -341,7 +346,7 @@ mod tests { METRIC.to_string(), MetricFacts { labels: labels(&["service", "endpoint"]), - scrape_interval_secs: 15, + scrape_interval_ms: 15_000, cardinality: BTreeMap::from([ (labels(&["service"]), 5), (labels(&["service", "endpoint"]), 50), @@ -350,7 +355,7 @@ mod tests { )]); let r = Raqe { grouping_labels: labels(&["service"]), - ..raqe(60, 60) + ..raqe(60_000, 60_000) }; assert_eq!(validate_facts(&[r], &facts), Ok(())); } @@ -361,18 +366,18 @@ mod tests { METRIC.to_string(), MetricFacts { labels: labels(&["service"]), - scrape_interval_secs: 0, + scrape_interval_ms: 0, cardinality: BTreeMap::from([(labels(&["service"]), 0)]), }, )]); let raqes = [ Raqe { metric: "missing".into(), - ..raqe(60, 60) + ..raqe(60_000, 60_000) }, Raqe { grouping_labels: labels(&["pod"]), - ..raqe(60, 60) + ..raqe(60_000, 60_000) }, ]; let problems = validate_facts(&raqes, &facts).unwrap_err(); @@ -391,7 +396,7 @@ mod tests { METRIC.to_string(), MetricFacts { labels: labels(&["service", "endpoint"]), - scrape_interval_secs: 15, + scrape_interval_ms: 15_000, cardinality: BTreeMap::from([ (labels(&["service"]), 60), (labels(&["service", "endpoint"]), 50), @@ -400,7 +405,7 @@ mod tests { )]); let r = Raqe { grouping_labels: labels(&["service"]), - ..raqe(60, 60) + ..raqe(60_000, 60_000) }; let problems = validate_facts(&[r], &facts).unwrap_err(); assert!(problems[0].contains("60 groups but only 50 series")); diff --git a/rqe-optimizer/src/milp.rs b/rqe-optimizer/src/milp.rs index 5dbb31ae..3dd32351 100644 --- a/rqe-optimizer/src/milp.rs +++ b/rqe-optimizer/src/milp.rs @@ -229,13 +229,13 @@ mod tests { /// Window and slide 60 s, merge 1 CPU-sec. fn deployment(insert_cpu_secs: f64, memory: f64, query_cpu_secs: f64) -> Deployment { - test_support::deployment(memory, insert_cpu_secs, 1.0, query_cpu_secs, 60, 60) + test_support::deployment(memory, insert_cpu_secs, 1.0, query_cpu_secs, 60_000, 60_000) } - fn raqe(id: &str, lookback_secs: u64) -> Raqe { + fn raqe(id: &str, lookback_ms: u64) -> Raqe { Raqe { id: id.into(), - ..test_support::raqe(lookback_secs, 60) + ..test_support::raqe(lookback_ms, 60_000) } } @@ -272,7 +272,7 @@ mod tests { #[test] fn minimizes_cpu_and_respects_a_latency_bound() { - let raqes = vec![raqe("r", 60)]; + let raqes = vec![raqe("r", 60_000)]; // CPU 1 + 3/60 with latency 3, or 2 + 1/60 with latency 1. let deployments = vec![deployment(1.0, 20.0, 3.0), deployment(2.0, 10.0, 1.0)]; let solve = |bounds: &MilpBounds| { @@ -296,7 +296,7 @@ mod tests { #[test] fn matches_brute_force_with_shared_deployment_costs() { - let raqes = vec![raqe("frequent", 60), raqe("long", 600)]; + let raqes = vec![raqe("frequent", 60_000), raqe("long", 600_000)]; let deployments = vec![ deployment(1.0, 0.5 * BYTES_PER_GIB, 400.0), deployment(3.0, 0.1 * BYTES_PER_GIB, 1.0), @@ -309,7 +309,7 @@ mod tests { #[test] fn memory_weight_trades_cpu_for_memory() { - let raqes = vec![raqe("r", 60)]; + let raqes = vec![raqe("r", 60_000)]; // CPU-heavy and small, or CPU-light and twice the size. let deployments = vec![ deployment(1.0, 1.0 * BYTES_PER_GIB, 0.0), @@ -335,7 +335,7 @@ mod tests { fn tiny_magnitudes_match_brute_force_and_respect_latency_bounds() { // Real plans cost ~1e-6 CPU-sec/sec with µs latencies: below HiGHS's // absolute gap and feasibility tolerances unless the model is scaled. - let raqes = vec![raqe("frequent", 60), raqe("long", 600)]; + let raqes = vec![raqe("frequent", 60_000), raqe("long", 600_000)]; let tiny = 1e-9; let deployments = vec![ deployment(1.0 * tiny, 0.5 * tiny * BYTES_PER_GIB, 400.0 * tiny), @@ -354,7 +354,7 @@ mod tests { // The cheap deployment's latency is 1.5e-7 s, over a 1e-7 s bound by // less than HiGHS's absolute feasibility tolerance. - let raqes = vec![raqe("r", 60)]; + let raqes = vec![raqe("r", 60_000)]; let deployments = vec![ deployment(tiny, tiny, 1.5e-7), deployment(10.0 * tiny, tiny, 0.5e-7), From 0ca016df299ef0f00e9ced926f58f1241a948c33 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 11:55:15 -0400 Subject: [PATCH 3/7] rqe-optimizer: spatial_filter and latency_sla_ms on Raqe, drop MilpBounds Stream identity is now (capability, metric, spatial_filter, grouping_labels). spatial_filter is opaque matcher text used for identity only. The latency limit moves from MilpBounds onto each Raqe; the example's --latency-limit RAQE_ID=SECONDS becomes --latency-sla RAQE_ID=MS. Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/examples/small_problem.rs | 54 ++++++++----- rqe-optimizer/src/autosketch.rs | 4 + rqe-optimizer/src/candidates.rs | 38 ++++++++- rqe-optimizer/src/enumerate.rs | 4 +- rqe-optimizer/src/lib.rs | 15 ++++ rqe-optimizer/src/milp.rs | 100 +++++++----------------- 6 files changed, 119 insertions(+), 96 deletions(-) diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index a368b19f..5086c04c 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -23,7 +23,7 @@ //! `--print-first N` to display example mappings. `--milp` minimizes //! `w_cpu · CPU + w_mem · memory GiB` without enumerating mappings; set the //! weights with `--w-cpu X --w-mem Y` (default 1 and 0). Repeat -//! `--latency-limit RAQE_ID=SECONDS` to impose MILP latency bounds. +//! `--latency-sla RAQE_ID=MS` to impose MILP latency SLAs. //! `--sample-mappings N` prints N feasible mappings and exits. use std::collections::BTreeSet; @@ -34,7 +34,7 @@ use rqe_optimizer::candidates::{ build_all_candidates, build_all_candidates_unpruned, eligible_deployments_for, }; use rqe_optimizer::enumerate::{brute_force, for_each_mapping, for_each_mapping_while, unservable}; -use rqe_optimizer::milp::{minimize, MilpBounds, Objective}; +use rqe_optimizer::milp::{minimize, Objective}; use rqe_optimizer::pareto::{pareto_front, ParetoFront}; use rqe_optimizer::{ validate_facts, AccuracyDirection, AtomicCostTable, Capability, LabelSet, MetricFacts, Raqe, @@ -108,10 +108,12 @@ fn raqes() -> Vec { lookback_ms: 3_600_000, interval_ms: 60_000, metric: requests(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "req_rate_1d".to_string(), @@ -119,10 +121,12 @@ fn raqes() -> Vec { lookback_ms: 86_400_000, interval_ms: 60_000, metric: requests(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "req_rate_5m_tick".to_string(), @@ -130,10 +134,12 @@ fn raqes() -> Vec { lookback_ms: 3_600_000, interval_ms: 300_000, metric: requests(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RATE_ERR.to_string(), accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "latency_p99_1h".to_string(), @@ -141,10 +147,12 @@ fn raqes() -> Vec { lookback_ms: 3_600_000, interval_ms: 60_000, metric: duration(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "latency_p99_6h_tick".to_string(), @@ -152,10 +160,12 @@ fn raqes() -> Vec { lookback_ms: 21_600_000, interval_ms: 300_000, metric: duration(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "latency_p99_1d".to_string(), @@ -163,10 +173,12 @@ fn raqes() -> Vec { lookback_ms: 86_400_000, interval_ms: 60_000, metric: duration(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: RANK_ERR.to_string(), accuracy_sla: 0.05, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, Raqe { id: "distinct_services_1h".to_string(), @@ -174,10 +186,12 @@ fn raqes() -> Vec { lookback_ms: 3_600_000, interval_ms: 60_000, metric: requests(), + spatial_filter: String::new(), grouping_labels: s.clone(), accuracy_metric: CARDINALITY_ERR.to_string(), accuracy_sla: 0.1, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, }, // The higher-is-better case: 0.9 is a *floor* on precision@k, not a // ceiling on error. Served by cms-heap, whose measured precision is @@ -189,10 +203,12 @@ fn raqes() -> Vec { lookback_ms: 3_600_000, interval_ms: 60_000, metric: requests(), + spatial_filter: String::new(), grouping_labels: se.clone(), accuracy_metric: TOPK_PRECISION.to_string(), accuracy_sla: 0.9, accuracy_direction: AccuracyDirection::HigherIsBetter, + latency_sla_ms: None, }, ] } @@ -243,28 +259,26 @@ fn print_candidate(candidate_number: usize, deployment: &rqe_optimizer::Deployme ); } -fn latency_bounds(raqes: &[Raqe]) -> Vec> { +fn apply_latency_slas(raqes: &mut [Raqe]) { let args: Vec<_> = std::env::args().collect(); - let mut bounds = vec![None; raqes.len()]; - for pair in args.windows(2).filter(|pair| pair[0] == "--latency-limit") { - let (id, seconds) = pair[1] + for pair in args.windows(2).filter(|pair| pair[0] == "--latency-sla") { + let (id, ms) = pair[1] .split_once('=') - .unwrap_or_else(|| panic!("--latency-limit expects RAQE_ID=SECONDS")); - let seconds = seconds + .unwrap_or_else(|| panic!("--latency-sla expects RAQE_ID=MS")); + let ms = ms .parse::() .ok() .filter(|value| *value > 0.0) - .unwrap_or_else(|| panic!("latency limit must be a positive number of seconds")); - let index = raqes - .iter() - .position(|raqe| raqe.id == id) - .unwrap_or_else(|| panic!("unknown RAQE in --latency-limit: {id}")); + .unwrap_or_else(|| panic!("latency SLA must be a positive number of milliseconds")); + let raqe = raqes + .iter_mut() + .find(|raqe| raqe.id == id) + .unwrap_or_else(|| panic!("unknown RAQE in --latency-sla: {id}")); assert!( - bounds[index].replace(seconds).is_none(), - "duplicate latency limit for {id}" + raqe.latency_sla_ms.replace(ms).is_none(), + "duplicate latency SLA for {id}" ); } - bounds } fn weight_flag(name: &str, default: f64) -> f64 { @@ -301,7 +315,8 @@ fn print_plan_cost(label: &str, plan_cost: &PlanCost) { fn main() { let candidates_only = std::env::args().any(|arg| arg == "--candidates-only"); - let raqes = raqes(); + let mut raqes = raqes(); + apply_latency_slas(&mut raqes); let cost_table = load_cost_table(); let facts = facts(); if let Err(problems) = validate_facts(&raqes, &facts) { @@ -374,14 +389,11 @@ fn main() { } if std::env::args().any(|arg| arg == "--milp") { - let bounds = MilpBounds { - max_query_latency_secs: latency_bounds(&raqes), - }; let objective = Objective::AUCCost { w_cpu: weight_flag("--w-cpu", 1.0), w_mem: weight_flag("--w-mem", 0.0), }; - let solution = minimize(&raqes, &deployments, &facts, &bounds, objective) + let solution = minimize(&raqes, &deployments, &facts, objective) .expect("small_problem MILP should be feasible"); println!( "MILP solution for {objective:?}: {:.3e}", diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index 8be8a382..3562616a 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -73,6 +73,7 @@ pub fn plan( Deployment { capability: raqe.capability, metric: raqe.metric.clone(), + spatial_filter: raqe.spatial_filter.clone(), grouping_labels: raqe.grouping_labels.clone(), config: costs[s.selected.expect("unservable RAQEs returned above")].clone(), window_ms, @@ -358,10 +359,12 @@ mod tests { lookback_ms: lookback, interval_ms: interval, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), accuracy_metric: ERR.into(), accuracy_sla: tolerance, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, } } @@ -529,6 +532,7 @@ mod tests { .collect(); let r = Raqe { accuracy_direction: AccuracyDirection::HigherIsBetter, + latency_sla_ms: None, ..quantile_raqe(0.95) }; let found = search(&r, &costs, 11, table_accuracy); diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index cd57258b..3d7ecb72 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -77,6 +77,7 @@ fn candidate_deployments( deployments.push(Deployment { capability, metric: group[0].metric.clone(), + spatial_filter: group[0].spatial_filter.clone(), grouping_labels: group[0].grouping_labels.clone(), config: config.clone(), window_ms, @@ -106,16 +107,21 @@ pub fn build_all_candidates_unpruned( costs: &[AtomicCostEntry], facts: &WorkloadFacts, ) -> Vec { - let mut groups: BTreeMap<(Capability, &str, &LabelSet), Vec<&Raqe>> = BTreeMap::new(); + let mut groups: BTreeMap<(Capability, &str, &str, &LabelSet), Vec<&Raqe>> = BTreeMap::new(); for raqe in raqes { groups - .entry((raqe.capability, &raqe.metric, &raqe.grouping_labels)) + .entry(( + raqe.capability, + &raqe.metric, + &raqe.spatial_filter, + &raqe.grouping_labels, + )) .or_default() .push(raqe); } groups .iter() - .flat_map(|(&(_, metric, _), group)| { + .flat_map(|(&(_, metric, _, _), group)| { candidate_deployments(group, costs, facts[metric].scrape_interval_ms) }) .collect() @@ -184,6 +190,7 @@ fn dominance( ) -> Dominance { let same_group = replacement.capability == original.capability && replacement.metric == original.metric + && replacement.spatial_filter == original.spatial_filter && replacement.grouping_labels == original.grouping_labels; let covers_original = original_coverage .iter() @@ -281,6 +288,7 @@ pub fn eligible_deployments_for(r: &Raqe, deployments: &[Deployment]) -> Vec bool { r.capability == d.capability && r.metric == d.metric + && r.spatial_filter == d.spatial_filter && r.grouping_labels == d.grouping_labels && d.window_ms != 0 && d.slide_ms != 0 @@ -303,10 +311,12 @@ mod tests { lookback_ms: lookback, interval_ms: interval, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), accuracy_sla: 1.0, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, } } fn cost() -> AtomicCostEntry { @@ -343,6 +353,23 @@ mod tests { .iter() .all(|d| d.window_ms % 15_000 == 0 && d.slide_ms % 15_000 == 0)); } + #[test] + fn different_spatial_filters_never_share_a_deployment() { + let unfiltered = raqe("a", 60_000, 60_000); + let filtered = Raqe { + spatial_filter: r#"{job="api"}"#.into(), + ..raqe("b", 60_000, 60_000) + }; + let candidates = build_all_candidates( + &[unfiltered.clone(), filtered.clone()], + &[cost()], + &facts(1, 1), + ); + assert!(candidates + .iter() + .all(|d| is_eligible(&unfiltered, d) != is_eligible(&filtered, d))); + } + #[test] fn a_min_query_is_only_offered_the_min_accumulator() { let r = Raqe { @@ -368,6 +395,7 @@ mod tests { let d = Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: cost(), window_ms: 120_000, @@ -396,6 +424,7 @@ mod tests { let coarse = Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: cost(), window_ms: 60_000, @@ -418,6 +447,7 @@ mod tests { let large_window = Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: cost.clone(), window_ms: 60_000, @@ -442,6 +472,7 @@ mod tests { let whole_window = Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: AtomicCostEntry { mem_bytes_per_instance: 10.0, @@ -475,6 +506,7 @@ mod tests { let direct = Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: AtomicCostEntry { mem_bytes_per_instance: 10.0, diff --git a/rqe-optimizer/src/enumerate.rs b/rqe-optimizer/src/enumerate.rs index f2aea70b..22033ba2 100644 --- a/rqe-optimizer/src/enumerate.rs +++ b/rqe-optimizer/src/enumerate.rs @@ -2,7 +2,7 @@ //! //! A mapping is one deployment choice per RAQE (§4's only constraint is //! `Σ_D x_{i,D} = 1`; `y_D` is derived). No RAQE is eligible outside its own -//! `(capability, metric, grouping_labels)` group, so choices are independent and the search +//! `(capability, metric, spatial_filter, grouping_labels)` group, so choices are independent and the search //! space is exactly the cartesian product of the per-RAQE eligible lists -- //! nothing to prune against. Sharing falls out when two choices land on the //! same index. @@ -140,10 +140,12 @@ mod tests { lookback_ms: lookback, interval_ms: interval, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), accuracy_metric: "err".to_string(), accuracy_sla: 1.0, accuracy_direction: crate::AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, } } diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 12eb7253..9ebc7502 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -168,6 +168,11 @@ pub struct Raqe { /// `T_i`: how often this RAQE is queried. pub interval_ms: Millis, pub metric: String, + /// Series selector beyond the metric name, as canonical PromQL matcher + /// text; empty for none. Compared as an opaque string. + // ponytail: identity only. Filters that select the same series but are + // spelled differently don't share, and facts ignore the filter. + pub spatial_filter: String, /// `G`: the query's group-by labels. pub grouping_labels: LabelSet, /// Which `AtomicCostEntry::query_accuracy` key to check. Comparators @@ -177,6 +182,9 @@ pub struct Raqe { /// [`Raqe::accuracy_direction`]. pub accuracy_sla: f64, pub accuracy_direction: AccuracyDirection, + /// Hard ceiling on modeled query latency; `None` for no limit. Only the + /// MILP enforces it. + pub latency_sla_ms: Option, } impl Raqe { @@ -206,6 +214,7 @@ impl Raqe { pub struct Deployment { pub capability: Capability, pub metric: String, + pub spatial_filter: String, /// `G`: one instance per group, per window. pub grouping_labels: LabelSet, pub config: AtomicCostEntry, @@ -277,10 +286,12 @@ pub(crate) mod test_support { lookback_ms, interval_ms, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), accuracy_metric: "err".into(), accuracy_sla: 1.0, accuracy_direction: AccuracyDirection::LowerIsBetter, + latency_sla_ms: None, } } @@ -296,6 +307,7 @@ pub(crate) mod test_support { Deployment { capability: Capability::TopK, metric: METRIC.into(), + spatial_filter: String::new(), grouping_labels: LabelSet::new(), config: AtomicCostEntry { sketch: "cms-heap-topk-fastpath-vector2d".into(), @@ -354,6 +366,7 @@ mod tests { }, )]); let r = Raqe { + spatial_filter: String::new(), grouping_labels: labels(&["service"]), ..raqe(60_000, 60_000) }; @@ -376,6 +389,7 @@ mod tests { ..raqe(60_000, 60_000) }, Raqe { + spatial_filter: String::new(), grouping_labels: labels(&["pod"]), ..raqe(60_000, 60_000) }, @@ -404,6 +418,7 @@ mod tests { }, )]); let r = Raqe { + spatial_filter: String::new(), grouping_labels: labels(&["service"]), ..raqe(60_000, 60_000) }; diff --git a/rqe-optimizer/src/milp.rs b/rqe-optimizer/src/milp.rs index 3dd32351..59fa989b 100644 --- a/rqe-optimizer/src/milp.rs +++ b/rqe-optimizer/src/milp.rs @@ -12,7 +12,7 @@ use good_lp::{ }; /// What a [`minimize`] solve minimizes. Eligibility, assignment and activation -/// rows, and bounds are the same for every objective. +/// rows, and latency SLAs are the same for every objective. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Objective { /// `w_cpu · CPU + w_mem · memory`. CPU is mean CPU-sec/sec, the area under @@ -45,20 +45,13 @@ impl Objective { } } -/// Optional hard bounds for a solve. An empty latency vector means no RAQE has -/// a latency bound; otherwise it is index-aligned with `raqes`. -#[derive(Debug, Clone, Default)] -pub struct MilpBounds { - pub max_query_latency_secs: Vec>, -} - #[derive(Debug, Clone)] pub struct MilpSolution { pub mapping: Mapping, pub plan_cost: PlanCost, } -/// Minimize `objective` subject to optional latency bounds. `facts` must pass +/// Minimize `objective` subject to each RAQE's latency SLA. `facts` must pass /// [`crate::validate_facts`]. Missing eligible deployments are a caller input /// error; use `enumerate::unservable` to report them before calling this /// function. @@ -66,15 +59,8 @@ pub fn minimize( raqes: &[Raqe], deployments: &[Deployment], facts: &WorkloadFacts, - bounds: &MilpBounds, objective: Objective, ) -> Result { - assert!( - bounds.max_query_latency_secs.is_empty() - || bounds.max_query_latency_secs.len() == raqes.len(), - "latency bounds must be empty or index-aligned with raqes" - ); - let eligible: Vec> = raqes .iter() .map(|raqe| eligible_deployments_for(raqe, deployments)) @@ -124,7 +110,7 @@ pub fn minimize( // Real plans cost ~1e-6 CPU-sec/sec and read in microseconds, below // HiGHS's absolute gap (1e-6) and feasibility tolerance (1e-7). So costs // are divided by `reference`, a plan's cost without sharing (each RAQE on - // its cheapest deployment), and per-RAQE bounds become exclusions. The + // its cheapest deployment), and latency SLAs become exclusions. The // result is re-scored on real costs. let reference: f64 = raqes .iter() @@ -179,17 +165,12 @@ pub fn minimize( .leq(0), ); } - // Each RAQE takes exactly one deployment, so a per-RAQE bound just + // Each RAQE takes exactly one deployment, so its latency SLA just // forbids the choices over it. Comparing in f64 here, not in the // solver, keeps µs latencies clear of its feasibility tolerance. - let latency = analytical_cost_model::query_latency_secs(raqe, deployment, facts); - if bounds - .max_query_latency_secs - .get(raqe_index) - .copied() - .flatten() - .is_some_and(|limit| latency > limit) - { + let latency_ms = + 1000.0 * analytical_cost_model::query_latency_secs(raqe, deployment, facts); + if raqe.latency_sla_ms.is_some_and(|limit| latency_ms > limit) { model.add_constraint(Expression::from(assignment).leq(0)); } } @@ -255,14 +236,7 @@ mod tests { .map(|mapping| objective.value(&score(raqes, deployments, mapping, &facts))) .min_by(f64::total_cmp) .expect("test workload is servable"); - let milp = minimize( - raqes, - deployments, - &facts, - &MilpBounds::default(), - objective, - ) - .expect("feasible MILP"); + let milp = minimize(raqes, deployments, &facts, objective).expect("feasible MILP"); let got = objective.value(&milp.plan_cost); assert!( (got - best).abs() <= 1e-9 * best, @@ -271,27 +245,22 @@ mod tests { } #[test] - fn minimizes_cpu_and_respects_a_latency_bound() { + fn minimizes_cpu_and_respects_a_latency_sla() { let raqes = vec![raqe("r", 60_000)]; // CPU 1 + 3/60 with latency 3, or 2 + 1/60 with latency 1. let deployments = vec![deployment(1.0, 20.0, 3.0), deployment(2.0, 10.0, 1.0)]; - let solve = |bounds: &MilpBounds| { - minimize( - &raqes, - &deployments, - &facts(1, 1), - bounds, - Objective::default(), - ) - .expect("feasible MILP") - .mapping + let solve = |raqes: &[Raqe]| { + minimize(raqes, &deployments, &facts(1, 1), Objective::default()) + .expect("feasible MILP") + .mapping }; - assert_eq!(solve(&MilpBounds::default()), vec![0]); - let bounded = MilpBounds { - max_query_latency_secs: vec![Some(2.0)], + assert_eq!(solve(&raqes), vec![0]); + let bounded = Raqe { + latency_sla_ms: Some(2_000.0), + ..raqes[0].clone() }; - assert_eq!(solve(&bounded), vec![1]); + assert_eq!(solve(&[bounded]), vec![1]); } #[test] @@ -316,15 +285,9 @@ mod tests { deployment(0.05, 2.0 * BYTES_PER_GIB, 0.0), ]; let solve = |objective| { - minimize( - &raqes, - &deployments, - &facts(1, 1), - &MilpBounds::default(), - objective, - ) - .expect("feasible MILP") - .mapping + minimize(&raqes, &deployments, &facts(1, 1), objective) + .expect("feasible MILP") + .mapping }; assert_eq!(solve(weights(1.0, 0.0)), vec![1]); @@ -332,7 +295,7 @@ mod tests { } #[test] - fn tiny_magnitudes_match_brute_force_and_respect_latency_bounds() { + fn tiny_magnitudes_match_brute_force_and_respect_latency_slas() { // Real plans cost ~1e-6 CPU-sec/sec with µs latencies: below HiGHS's // absolute gap and feasibility tolerances unless the model is scaled. let raqes = vec![raqe("frequent", 60_000), raqe("long", 600_000)]; @@ -352,23 +315,18 @@ mod tests { assert_matches_brute_force(&raqes, &deployments, objective); } - // The cheap deployment's latency is 1.5e-7 s, over a 1e-7 s bound by + // The cheap deployment's latency is 1.5e-7 s, over a 1e-7 s SLA by // less than HiGHS's absolute feasibility tolerance. - let raqes = vec![raqe("r", 60_000)]; + let raqes = vec![Raqe { + latency_sla_ms: Some(1e-4), + ..raqe("r", 60_000) + }]; let deployments = vec![ deployment(tiny, tiny, 1.5e-7), deployment(10.0 * tiny, tiny, 0.5e-7), ]; - let milp = minimize( - &raqes, - &deployments, - &facts(1, 1), - &MilpBounds { - max_query_latency_secs: vec![Some(1e-7)], - }, - Objective::default(), - ) - .expect("feasible MILP"); + let milp = minimize(&raqes, &deployments, &facts(1, 1), Objective::default()) + .expect("feasible MILP"); assert_eq!(milp.mapping, vec![1]); assert!(milp.plan_cost.query_latency_secs[0] <= 1e-7); } From 59339bf664498f7737d1753736d48d7367e0fa18 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 12:02:40 -0400 Subject: [PATCH 4/7] rqe-optimizer: only ASAPQuery-deployable families become candidates DEPLOYABLE_FAMILIES lists the variants ASAPQuery's engine runs, each with its AggregationType. Candidate generation and the AutoSketch baseline both go through Capability::deployable_families(), so dd, univmon-* and countsketch-heap are measured but never planned. AutoSketch's chooses_the_cheaper_variant test is removed: no capability has two deployable variants until hydra-kll is measured. Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/src/autosketch.rs | 20 +++-------------- rqe-optimizer/src/candidates.rs | 25 +++++++++++++++++---- rqe-optimizer/src/lib.rs | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index 3562616a..c51bf53a 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -10,8 +10,8 @@ //! //! One extension over the paper: AutoSketch's compiler maps an operator to one //! sketch algorithm and tunes its parameters, while this search covers every -//! variant serving the RAQE's capability and keeps the cheapest, so it chooses -//! from the same sketches as the MILP. +//! deployable variant serving the RAQE's capability and keeps the cheapest, +//! so it chooses from the same sketches as the MILP. use crate::candidates::gcd; use crate::{AtomicCostEntry, Deployment, Mapping, Millis, Raqe}; @@ -100,8 +100,7 @@ pub fn search( let started = Instant::now(); let grids: Vec = raqe .capability - .families() - .iter() + .deployable_families() .filter_map(|variant| Grid::new(variant, costs)) .collect(); @@ -508,18 +507,6 @@ mod tests { } } - #[test] - fn chooses_the_cheaper_variant() { - let mut costs = grid(&[2, 3], &[256, 512]); - let mut cs = cms(2, 256); - cs.sketch = "countsketch-heap-topk-fastpath-vector2d".into(); - cs.mem_bytes_per_instance = 100.0; - costs.push(cs); - let r = raqe("r", 3_600_000, 60_000, 0.01); - let found = search(&r, &costs, 3, table_accuracy); - assert_eq!(found.selected, Some(costs.len() - 1)); - } - #[test] fn higher_is_better_metric_is_a_floor() { // Precision rises with k; the target is a floor of 0.95. @@ -532,7 +519,6 @@ mod tests { .collect(); let r = Raqe { accuracy_direction: AccuracyDirection::HigherIsBetter, - latency_sla_ms: None, ..quantile_raqe(0.95) }; let found = search(&r, &costs, 11, table_accuracy); diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index 3d7ecb72..2c85846a 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -68,10 +68,11 @@ fn candidate_deployments( .filter(|r| r.lookback_ms % window_ms == 0) .map(|r| gcd(window_ms, r.interval_ms)), ); - for config in costs - .iter() - .filter(|c| capability.families().contains(&c.sketch.as_str())) - { + for config in costs.iter().filter(|c| { + capability + .deployable_families() + .any(|family| family == c.sketch) + }) { for &slide_ms in &slides { if slide_ms.is_multiple_of(scrape_interval_ms) { deployments.push(Deployment { @@ -389,6 +390,22 @@ mod tests { assert!(candidates.iter().all(|d| d.config.sketch == "exact-min")); } + #[test] + fn undeployable_families_are_not_candidates() { + let r = Raqe { + capability: Capability::Quantile, + ..raqe("r", 60_000, 60_000) + }; + let named = |sketch: &str| AtomicCostEntry { + sketch: sketch.into(), + ..cost() + }; + let candidates = + build_all_candidates(&[r], &[named("kll-percall"), named("dd")], &facts(1, 1)); + assert!(!candidates.is_empty()); + assert!(candidates.iter().all(|d| d.config.sketch == "kll-percall")); + } + #[test] fn eligibility_requires_exact_non_overlapping_tiling() { let r = raqe("r", 600_000, 180_000); diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 9ebc7502..081e04b8 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -74,8 +74,29 @@ impl Capability { ], } } + + /// The subset of [`Capability::families`] in [`DEPLOYABLE_FAMILIES`]: + /// the only variants that become candidates. + pub fn deployable_families(self) -> impl Iterator { + self.families() + .iter() + .copied() + .filter(|family| DEPLOYABLE_FAMILIES.contains(family)) + } } +/// Variants ASAPQuery can deploy, each with the `AggregationType` its query +/// engine runs. Both sides use `asap_sketchlib` for the sketches. +pub const DEPLOYABLE_FAMILIES: &[&str] = &[ + "exact-sum", // MultipleSum + "exact-min", // MultipleMinMax, sub_type "min" + "exact-max", // MultipleMinMax, sub_type "max" + "exact-increase", // MultipleIncrease + "kll-percall", // DatasketchesKLL + "hll", // HLL + "cms-heap-topk-fastpath-vector2d", // CountMinSketchWithHeap +]; + /// Facts about one metric, given as input. RAQEs and deployments on the same /// metric read the same samples. #[derive(Debug, Clone)] @@ -333,6 +354,24 @@ mod tests { names.iter().map(|s| s.to_string()).collect() } + #[test] + fn every_deployable_family_serves_a_capability() { + let capabilities = [ + Capability::SumOrCount, + Capability::Min, + Capability::Max, + Capability::RateOrIncrease, + Capability::Quantile, + Capability::Cardinality, + Capability::TopK, + ]; + let served: usize = capabilities + .iter() + .map(|capability| capability.deployable_families().count()) + .sum(); + assert_eq!(served, DEPLOYABLE_FAMILIES.len()); + } + #[test] fn closed_instances_lie_wholly_inside_the_lookback() { // 10-minute windows every minute, over an hour: starts in minutes From 3715b86b41712381125ed1d1a69d4f40f846031f Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 13:03:06 -0400 Subject: [PATCH 5/7] rqe-optimizer: address #148 review - Latency is modeled in ms end to end (PlanCost::query_latency_ms), so the SLA compares without a unit conversion and the example prints ms. - validate_facts rejects non-finite or non-positive latency SLAs, and non-empty spatial filters until facts carry per-filter cardinality. - small_problem rejects --latency-sla without --milp. - Deployable-family test checks each entry is in some families(). - Restore AutoSketch's cross-variant test via search_variants(). - Fix the families() and export-script comments; drop redundant spatial_filter fields in tests. Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/examples/small_problem.rs | 13 ++++-- rqe-optimizer/src/analytical_cost_model.rs | 21 ++++----- rqe-optimizer/src/autosketch.rs | 41 ++++++++++++++++-- rqe-optimizer/src/lib.rs | 50 +++++++++++++++------- rqe-optimizer/src/milp.rs | 5 +-- rqe-optimizer/src/pareto.rs | 4 +- scripts/export_rqe_optimizer_costs.sh | 6 ++- 7 files changed, 98 insertions(+), 42 deletions(-) diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index 5086c04c..89681b06 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -259,8 +259,13 @@ fn print_candidate(candidate_number: usize, deployment: &rqe_optimizer::Deployme ); } +/// Only `--milp` enforces latency SLAs, so other modes reject the flag. fn apply_latency_slas(raqes: &mut [Raqe]) { let args: Vec<_> = std::env::args().collect(); + assert!( + !args.iter().any(|arg| arg == "--latency-sla") || args.iter().any(|arg| arg == "--milp"), + "--latency-sla is only enforced with --milp" + ); for pair in args.windows(2).filter(|pair| pair[0] == "--latency-sla") { let (id, ms) = pair[1] .split_once('=') @@ -401,8 +406,8 @@ fn main() { ); print_plan_cost(" totals", &solution.plan_cost); print_mapping(1, &solution.mapping, &raqes, &deployments); - for (raqe, latency) in raqes.iter().zip(&solution.plan_cost.query_latency_secs) { - println!(" {}: query_latency={latency:.3e} sec", raqe.id); + for (raqe, latency) in raqes.iter().zip(&solution.plan_cost.query_latency_ms) { + println!(" {}: query_latency={latency:.3e} ms", raqe.id); } return; } @@ -459,9 +464,9 @@ fn main() { &format!("mapping {i}: {} deployments", distinct_deployments.len()), plan_cost, ); - for (raqe, latency) in raqes.iter().zip(&plan_cost.query_latency_secs) { + for (raqe, latency) in raqes.iter().zip(&plan_cost.query_latency_ms) { let raqe_id = &raqe.id; - println!(" {raqe_id}: query_latency={latency:.3e} sec"); + println!(" {raqe_id}: query_latency={latency:.3e} ms"); } } } diff --git a/rqe-optimizer/src/analytical_cost_model.rs b/rqe-optimizer/src/analytical_cost_model.rs index 33820544..51f9ad7e 100644 --- a/rqe-optimizer/src/analytical_cost_model.rs +++ b/rqe-optimizer/src/analytical_cost_model.rs @@ -57,7 +57,7 @@ pub struct PlanCost { /// Paid once per active deployment, sized for its longest lookback. pub storage: PhaseCost, /// Index-aligned with the input RAQE slice; display IDs need not be unique. - pub query_latency_secs: Vec, + pub query_latency_ms: Vec, } impl PlanCost { @@ -150,14 +150,11 @@ pub(crate) fn storage_bytes(raqe: &Raqe, deployment: &Deployment, facts: &Worklo group_count(deployment, facts) * deployment.config.mem_bytes_per_instance * closed_windows } -/// Serial CPU time of one query: `card(G) · (c_qry + (L/x − 1) · c_mrg)`. -pub(crate) fn query_latency_secs( - raqe: &Raqe, - deployment: &Deployment, - facts: &WorkloadFacts, -) -> f64 { +/// Serial CPU time of one query, in ms: `card(G) · (c_qry + (L/x − 1) · c_mrg)`. +pub(crate) fn query_latency_ms(raqe: &Raqe, deployment: &Deployment, facts: &WorkloadFacts) -> f64 { let merges_per_group = merged_window_count(raqe, deployment) - 1.0; - group_count(deployment, facts) + 1000.0 + * group_count(deployment, facts) * (deployment.config.query_cpu_secs + merges_per_group * deployment.config.merge_cpu_secs) } @@ -181,7 +178,7 @@ pub fn score( let mut storage_bytes_by_deployment: BTreeMap = BTreeMap::new(); let mut merge_cost = PhaseCost::default(); let mut query_cost = PhaseCost::default(); - let query_latency_secs = raqes + let query_latency_ms = raqes .iter() .zip(mapping) .map(|(raqe, &deployment_index)| { @@ -192,7 +189,7 @@ pub fn score( *stored = stored.max(storage_bytes(raqe, deployment, facts)); merge_cost += merge(raqe, deployment, facts); query_cost += query(raqe, deployment, facts); - query_latency_secs(raqe, deployment, facts) + query_latency_ms(raqe, deployment, facts) }) .collect(); @@ -204,7 +201,7 @@ pub fn score( cpu_secs_per_sec: 0.0, memory_bytes: storage_bytes_by_deployment.values().sum(), }, - query_latency_secs, + query_latency_ms, } } @@ -234,7 +231,7 @@ mod tests { assert_eq!(result.query.memory_bytes, 2048.0); // 4 × 32 × 16 assert_eq!(result.storage.cpu_secs_per_sec, 0.0); assert_eq!(result.storage.memory_bytes, 200.0); // 4 × 10 × 5 - assert_eq!(result.query_latency_secs, vec![44.0]); // 4 × (5 + 2 × 3) + assert_eq!(result.query_latency_ms, vec![44_000.0]); // 4 × (5 + 2 × 3) s assert!((result.cpu_secs_per_sec() - (28.0 + 44.0 / 30.0)).abs() < 1e-12); assert_eq!(result.memory_bytes(), 80.0 + 40.0 + 2048.0 + 200.0); } diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index c51bf53a..c1278388 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -95,12 +95,28 @@ pub fn search( raqe: &Raqe, costs: &[AtomicCostEntry], seed: u64, + accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, +) -> RaqeSearch { + search_variants( + raqe, + raqe.capability.deployable_families(), + costs, + seed, + accuracy, + ) +} + +/// [`search`] over the given variants. +fn search_variants<'a>( + raqe: &Raqe, + variants: impl IntoIterator, + costs: &[AtomicCostEntry], + seed: u64, mut accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, ) -> RaqeSearch { let started = Instant::now(); - let grids: Vec = raqe - .capability - .deployable_families() + let grids: Vec = variants + .into_iter() .filter_map(|variant| Grid::new(variant, costs)) .collect(); @@ -507,6 +523,25 @@ mod tests { } } + #[test] + fn chooses_the_cheaper_variant() { + // Both top-k families, though only one is deployable today. + let mut costs = grid(&[2, 3], &[256, 512]); + let mut cs = cms(2, 256); + cs.sketch = "countsketch-heap-topk-fastpath-vector2d".into(); + cs.mem_bytes_per_instance = 100.0; + costs.push(cs); + let r = raqe("r", 3_600_000, 60_000, 0.01); + let found = search_variants( + &r, + r.capability.families().iter().copied(), + &costs, + 3, + table_accuracy, + ); + assert_eq!(found.selected, Some(costs.len() - 1)); + } + #[test] fn higher_is_better_metric_is_a_floor() { // Precision rises with k; the target is a floor of 0.95. diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 081e04b8..32946a0c 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -32,8 +32,8 @@ pub(crate) fn secs(ms: Millis) -> f64 { /// A group-by key, compared as a set (§3's `labels_i == labels_D` rule). pub type LabelSet = BTreeSet; -/// What an RAQE's statistic needs (§1). `families()` is the only place -/// capability scope is encoded; widening it means editing that match arm. +/// What an RAQE's statistic needs (§1). A variant becomes a candidate only +/// if it is in both `families()` and [`DEPLOYABLE_FAMILIES`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Capability { SumOrCount, @@ -120,11 +120,20 @@ impl MetricFacts { /// [`MetricFacts`] keyed by metric name. pub type WorkloadFacts = BTreeMap; -/// Every problem with `facts` for serving `raqes`. The rest of the crate -/// indexes `facts` without checks, so call this first on caller input. +/// Every problem with `raqes` and the `facts` serving them. The rest of the +/// crate indexes `facts` without checks, so call this first on caller input. pub fn validate_facts(raqes: &[Raqe], facts: &WorkloadFacts) -> Result<(), Vec> { let mut problems = BTreeSet::new(); for raqe in raqes { + let id = &raqe.id; + if !raqe.spatial_filter.is_empty() { + problems.insert(format!("{id}: spatial filters are not supported yet")); + } + if let Some(limit) = raqe.latency_sla_ms { + if !(limit.is_finite() && limit > 0.0) { + problems.insert(format!("{id}: latency SLA {limit} ms is not positive")); + } + } let (metric, grouping) = (&raqe.metric, &raqe.grouping_labels); let Some(metric_facts) = facts.get(metric) else { problems.insert(format!("{metric}: no facts for this metric")); @@ -190,9 +199,11 @@ pub struct Raqe { pub interval_ms: Millis, pub metric: String, /// Series selector beyond the metric name, as canonical PromQL matcher - /// text; empty for none. Compared as an opaque string. - // ponytail: identity only. Filters that select the same series but are - // spelled differently don't share, and facts ignore the filter. + /// text; empty for none. Part of stream identity, compared as an opaque + /// string. + // ponytail: validate_facts rejects non-empty filters until facts carry + // per-filter cardinality; without it a filtered stream is costed as the + // whole metric. pub spatial_filter: String, /// `G`: the query's group-by labels. pub grouping_labels: LabelSet, @@ -365,11 +376,12 @@ mod tests { Capability::Cardinality, Capability::TopK, ]; - let served: usize = capabilities - .iter() - .map(|capability| capability.deployable_families().count()) - .sum(); - assert_eq!(served, DEPLOYABLE_FAMILIES.len()); + for family in DEPLOYABLE_FAMILIES { + assert!( + capabilities.iter().any(|c| c.families().contains(family)), + "{family} serves no capability" + ); + } } #[test] @@ -405,7 +417,6 @@ mod tests { }, )]); let r = Raqe { - spatial_filter: String::new(), grouping_labels: labels(&["service"]), ..raqe(60_000, 60_000) }; @@ -428,10 +439,16 @@ mod tests { ..raqe(60_000, 60_000) }, Raqe { - spatial_filter: String::new(), grouping_labels: labels(&["pod"]), ..raqe(60_000, 60_000) }, + Raqe { + id: "filtered".into(), + grouping_labels: labels(&["service"]), + spatial_filter: r#"{job="api"}"#.into(), + latency_sla_ms: Some(f64::NAN), + ..raqe(60_000, 60_000) + }, ]; let problems = validate_facts(&raqes, &facts).unwrap_err(); let has = |needle: &str| problems.iter().any(|p| p.contains(needle)); @@ -440,7 +457,9 @@ mod tests { assert!(has("not a subset")); assert!(has("no cardinality for {\"pod\"}")); assert!(has("cardinality of {\"service\"} is 0")); - assert_eq!(problems.len(), 5, "{problems:?}"); + assert!(has("filtered: spatial filters are not supported")); + assert!(has("filtered: latency SLA NaN ms is not positive")); + assert_eq!(problems.len(), 7, "{problems:?}"); } #[test] @@ -457,7 +476,6 @@ mod tests { }, )]); let r = Raqe { - spatial_filter: String::new(), grouping_labels: labels(&["service"]), ..raqe(60_000, 60_000) }; diff --git a/rqe-optimizer/src/milp.rs b/rqe-optimizer/src/milp.rs index 59fa989b..5ebacdfe 100644 --- a/rqe-optimizer/src/milp.rs +++ b/rqe-optimizer/src/milp.rs @@ -168,8 +168,7 @@ pub fn minimize( // Each RAQE takes exactly one deployment, so its latency SLA just // forbids the choices over it. Comparing in f64 here, not in the // solver, keeps µs latencies clear of its feasibility tolerance. - let latency_ms = - 1000.0 * analytical_cost_model::query_latency_secs(raqe, deployment, facts); + let latency_ms = analytical_cost_model::query_latency_ms(raqe, deployment, facts); if raqe.latency_sla_ms.is_some_and(|limit| latency_ms > limit) { model.add_constraint(Expression::from(assignment).leq(0)); } @@ -328,6 +327,6 @@ mod tests { let milp = minimize(&raqes, &deployments, &facts(1, 1), Objective::default()) .expect("feasible MILP"); assert_eq!(milp.mapping, vec![1]); - assert!(milp.plan_cost.query_latency_secs[0] <= 1e-7); + assert!(milp.plan_cost.query_latency_ms[0] <= 1e-4); } } diff --git a/rqe-optimizer/src/pareto.rs b/rqe-optimizer/src/pareto.rs index 04c78615..a3694c7d 100644 --- a/rqe-optimizer/src/pareto.rs +++ b/rqe-optimizer/src/pareto.rs @@ -17,7 +17,7 @@ fn dominates(a: &[f64], b: &[f64]) -> bool { fn pareto_vector(plan_cost: &PlanCost) -> Vec { let mut vector = vec![plan_cost.cpu_secs_per_sec(), plan_cost.memory_bytes()]; - vector.extend(&plan_cost.query_latency_secs); + vector.extend(&plan_cost.query_latency_ms); vector } @@ -87,7 +87,7 @@ mod tests { merge: PhaseCost::default(), query: PhaseCost::default(), storage: PhaseCost::default(), - query_latency_secs: vec![latency], + query_latency_ms: vec![latency], } } diff --git a/scripts/export_rqe_optimizer_costs.sh b/scripts/export_rqe_optimizer_costs.sh index e7c19e3f..bb21d618 100755 --- a/scripts/export_rqe_optimizer_costs.sh +++ b/scripts/export_rqe_optimizer_costs.sh @@ -2,8 +2,10 @@ # Measure the small atomic-cost grid consumed by rqe-optimizer. # # This is intentionally separate from export_atomic_costs.sh: that script -# serves ASAPQuery's pinned grid, while this one covers exactly the variants -# named by rqe_optimizer::Capability::families(). +# serves ASAPQuery's pinned grid, while this one covers the variants named by +# rqe_optimizer::Capability::families(). Only those also in +# DEPLOYABLE_FAMILIES become candidates; the rest are measured for when the +# engine gains them, and for research baselines. # # Usage: # scripts/export_rqe_optimizer_costs.sh From e576ef7899047e59ba870aa98b4a483a0b401ec1 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 13:07:36 -0400 Subject: [PATCH 6/7] rqe-optimizer: --allow-undeployable-families build_all_candidates(_unpruned) take allow_undeployable_families; when set, every measured family in Capability::families() becomes a candidate. The small_problem example exposes it as --allow-undeployable-families. Co-Authored-By: Claude Opus 5.5 --- rqe-optimizer/examples/small_problem.rs | 10 ++++-- rqe-optimizer/src/candidates.rs | 42 +++++++++++++++++++------ rqe-optimizer/src/enumerate.rs | 2 +- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index 89681b06..b6c414ff 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -25,6 +25,8 @@ //! weights with `--w-cpu X --w-mem Y` (default 1 and 0). Repeat //! `--latency-sla RAQE_ID=MS` to impose MILP latency SLAs. //! `--sample-mappings N` prints N feasible mappings and exits. +//! `--allow-undeployable-families` also plans with measured families +//! ASAPQuery can't deploy (dd, univmon-*, countsketch-heap). use std::collections::BTreeSet; use std::time::Instant; @@ -328,9 +330,11 @@ fn main() { panic!("invalid workload facts: {problems:#?}"); } - let unpruned_count = - candidates_only.then(|| build_all_candidates_unpruned(&raqes, &cost_table, &facts).len()); - let deployments = build_all_candidates(&raqes, &cost_table, &facts); + let allow_undeployable = std::env::args().any(|arg| arg == "--allow-undeployable-families"); + let unpruned_count = candidates_only.then(|| { + build_all_candidates_unpruned(&raqes, &cost_table, &facts, allow_undeployable).len() + }); + let deployments = build_all_candidates(&raqes, &cost_table, &facts, allow_undeployable); println!( "{} RAQEs, {} candidate deployments (from {} real cost-table rows)", raqes.len(), diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index 2c85846a..9f27e7b5 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -1,7 +1,10 @@ //! Candidate generation and eligibility (§3). use crate::analytical_cost_model::merge_memory_per_group; -use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Millis, Raqe, WorkloadFacts}; +use crate::{ + AtomicCostEntry, Capability, Deployment, LabelSet, Millis, Raqe, WorkloadFacts, + DEPLOYABLE_FAMILIES, +}; use std::collections::{BTreeMap, BTreeSet}; pub fn gcd(a: Millis, b: Millis) -> Millis { @@ -47,6 +50,7 @@ fn candidate_deployments( group: &[&Raqe], costs: &[AtomicCostEntry], scrape_interval_ms: Millis, + allow_undeployable_families: bool, ) -> Vec { if group.is_empty() { return Vec::new(); @@ -69,9 +73,9 @@ fn candidate_deployments( .map(|r| gcd(window_ms, r.interval_ms)), ); for config in costs.iter().filter(|c| { - capability - .deployable_families() - .any(|family| family == c.sketch) + let sketch = c.sketch.as_str(); + capability.families().contains(&sketch) + && (allow_undeployable_families || DEPLOYABLE_FAMILIES.contains(&sketch)) }) { for &slide_ms in &slides { if slide_ms.is_multiple_of(scrape_interval_ms) { @@ -91,12 +95,19 @@ fn candidate_deployments( deployments } +/// Only [`DEPLOYABLE_FAMILIES`] become candidates unless +/// `allow_undeployable_families`, which plans with every measured family +/// (for studies; the plan can't be deployed). pub fn build_all_candidates( raqes: &[Raqe], costs: &[AtomicCostEntry], facts: &WorkloadFacts, + allow_undeployable_families: bool, ) -> Vec { - prune_dominated_candidates(raqes, build_all_candidates_unpruned(raqes, costs, facts)) + prune_dominated_candidates( + raqes, + build_all_candidates_unpruned(raqes, costs, facts, allow_undeployable_families), + ) } /// Generate the complete v1 candidate set before dominance pruning. @@ -107,6 +118,7 @@ pub fn build_all_candidates_unpruned( raqes: &[Raqe], costs: &[AtomicCostEntry], facts: &WorkloadFacts, + allow_undeployable_families: bool, ) -> Vec { let mut groups: BTreeMap<(Capability, &str, &str, &LabelSet), Vec<&Raqe>> = BTreeMap::new(); for raqe in raqes { @@ -123,7 +135,12 @@ pub fn build_all_candidates_unpruned( groups .iter() .flat_map(|(&(_, metric, _, _), group)| { - candidate_deployments(group, costs, facts[metric].scrape_interval_ms) + candidate_deployments( + group, + costs, + facts[metric].scrape_interval_ms, + allow_undeployable_families, + ) }) .collect() } @@ -334,7 +351,7 @@ mod tests { #[test] fn shared_slide_comes_from_subset_gcd_not_all_divisors() { let raqes = vec![raqe("a", 60_000, 20_000), raqe("b", 60_000, 30_000)]; - let candidates = build_all_candidates(&raqes, &[cost()], &facts(1, 1)); + let candidates = build_all_candidates(&raqes, &[cost()], &facts(1, 1), false); let slides: BTreeSet<_> = candidates .iter() .filter(|d| d.window_ms == 60_000) @@ -347,7 +364,7 @@ mod tests { let raqes = vec![raqe("a", 60_000, 20_000), raqe("b", 60_000, 30_000)]; let mut facts = facts(1, 1); facts.get_mut(METRIC).unwrap().scrape_interval_ms = 15_000; - let candidates = build_all_candidates_unpruned(&raqes, &[cost()], &facts); + let candidates = build_all_candidates_unpruned(&raqes, &[cost()], &facts, false); assert!(!candidates.is_empty()); // Without the filter, windows 1..60 and slides 10/20 would appear. assert!(candidates @@ -365,6 +382,7 @@ mod tests { &[unfiltered.clone(), filtered.clone()], &[cost()], &facts(1, 1), + false, ); assert!(candidates .iter() @@ -385,13 +403,14 @@ mod tests { &[r], &[named("exact-min"), named("exact-max")], &facts(1, 1), + false, ); assert!(!candidates.is_empty()); assert!(candidates.iter().all(|d| d.config.sketch == "exact-min")); } #[test] - fn undeployable_families_are_not_candidates() { + fn undeployable_families_are_candidates_only_when_allowed() { let r = Raqe { capability: Capability::Quantile, ..raqe("r", 60_000, 60_000) @@ -400,10 +419,13 @@ mod tests { sketch: sketch.into(), ..cost() }; + let costs = [named("kll-percall"), named("dd")]; let candidates = - build_all_candidates(&[r], &[named("kll-percall"), named("dd")], &facts(1, 1)); + build_all_candidates(std::slice::from_ref(&r), &costs, &facts(1, 1), false); assert!(!candidates.is_empty()); assert!(candidates.iter().all(|d| d.config.sketch == "kll-percall")); + let all = build_all_candidates_unpruned(&[r], &costs, &facts(1, 1), true); + assert!(all.iter().any(|d| d.config.sketch == "dd")); } #[test] diff --git a/rqe-optimizer/src/enumerate.rs b/rqe-optimizer/src/enumerate.rs index 22033ba2..abcaf7cd 100644 --- a/rqe-optimizer/src/enumerate.rs +++ b/rqe-optimizer/src/enumerate.rs @@ -130,7 +130,7 @@ mod tests { } fn build_all_candidates(raqes: &[Raqe], costs: &[AtomicCostEntry]) -> Vec { - candidates::build_all_candidates(raqes, costs, &facts(1, 1)) + candidates::build_all_candidates(raqes, costs, &facts(1, 1), false) } fn raqe(id: &str, interval: u64, lookback: u64) -> Raqe { From c4af9ade0eab96528ef31f62ad4591d9bd3583f2 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 6 Oct 2026 13:33:50 -0400 Subject: [PATCH 7/7] rqe-optimizer: address second #148 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capability::candidate_families(allow_undeployable_families) is the one family rule; candidate generation and AutoSketch's plan/search both take the flag, so the baseline sees the same sketches as the MILP. - validate_facts rejects zero lookback/interval and ones that aren't multiples of the scrape interval. - Candidate windows come from divisors(L / scrape) × scrape. - small_problem rejects --latency-sla with --candidates-only or --sample-mappings, and non-finite SLAs. - Capability::ALL for the deployable-family test; spatial_filter in the pruning comment and the TODO doc. Co-Authored-By: Claude Opus 5.5 --- docs/rqe_optimizer_TODO.md | 4 +- rqe-optimizer/examples/small_problem.rs | 15 +++-- rqe-optimizer/src/autosketch.rs | 62 +++++++++------------ rqe-optimizer/src/candidates.rs | 23 +++----- rqe-optimizer/src/lib.rs | 73 +++++++++++++++++++------ 5 files changed, 102 insertions(+), 75 deletions(-) diff --git a/docs/rqe_optimizer_TODO.md b/docs/rqe_optimizer_TODO.md index 348ab3c1..c8d92af0 100644 --- a/docs/rqe_optimizer_TODO.md +++ b/docs/rqe_optimizer_TODO.md @@ -5,12 +5,12 @@ implemented, what is next, and what is intentionally deferred. ## Done in v1 -- Sliding deployments use `(capability, configuration, metric, G, x, y)`, +- Sliding deployments use `(capability, configuration, metric, spatial_filter, G, x, y)`, where `x` is window size and `y` is slide. - Workload facts are per metric: full label set, scrape interval, and cardinality per label set. The arrival rate is derived from them, and `validate_facts` reports bad facts up front. -- Eligibility enforces capability, metric, grouping, alignment, and the RQE's +- Eligibility enforces capability, metric, spatial filter, grouping, alignment, and the RQE's measured `accuracy_metric` constraint. - Candidate generation uses legal windows and subset gcds of query intervals, keeping only multiples of the scrape interval. A deployment may serve diff --git a/rqe-optimizer/examples/small_problem.rs b/rqe-optimizer/examples/small_problem.rs index b6c414ff..83b3737a 100644 --- a/rqe-optimizer/examples/small_problem.rs +++ b/rqe-optimizer/examples/small_problem.rs @@ -261,12 +261,15 @@ fn print_candidate(candidate_number: usize, deployment: &rqe_optimizer::Deployme ); } -/// Only `--milp` enforces latency SLAs, so other modes reject the flag. +/// Only `--milp` enforces latency SLAs, so every other mode rejects the flag. +/// `--candidates-only` and `--sample-mappings` run before the MILP branch. fn apply_latency_slas(raqes: &mut [Raqe]) { let args: Vec<_> = std::env::args().collect(); + let has = |flag: &str| args.iter().any(|arg| arg == flag); assert!( - !args.iter().any(|arg| arg == "--latency-sla") || args.iter().any(|arg| arg == "--milp"), - "--latency-sla is only enforced with --milp" + !has("--latency-sla") + || (has("--milp") && !has("--candidates-only") && !has("--sample-mappings")), + "--latency-sla is only enforced with --milp, without --candidates-only or --sample-mappings" ); for pair in args.windows(2).filter(|pair| pair[0] == "--latency-sla") { let (id, ms) = pair[1] @@ -275,8 +278,10 @@ fn apply_latency_slas(raqes: &mut [Raqe]) { let ms = ms .parse::() .ok() - .filter(|value| *value > 0.0) - .unwrap_or_else(|| panic!("latency SLA must be a positive number of milliseconds")); + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or_else(|| { + panic!("latency SLA must be a finite positive number of milliseconds") + }); let raqe = raqes .iter_mut() .find(|raqe| raqe.id == id) diff --git a/rqe-optimizer/src/autosketch.rs b/rqe-optimizer/src/autosketch.rs index c1278388..72fb4e6d 100644 --- a/rqe-optimizer/src/autosketch.rs +++ b/rqe-optimizer/src/autosketch.rs @@ -47,15 +47,26 @@ pub fn window_adapter(raqe: &Raqe) -> (Millis, Millis) { /// Search every RAQE independently. `accuracy` is the benchmark oracle: the /// measured metric named by `raqe.accuracy_metric` for a configuration, or /// `None` when it was not measured. Returns the IDs of unservable RAQEs. +/// `allow_undeployable_families` as in +/// [`crate::Capability::candidate_families`]. pub fn plan( raqes: &[Raqe], costs: &[AtomicCostEntry], seed: u64, + allow_undeployable_families: bool, mut accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, ) -> Result> { let searches: Vec<_> = raqes .iter() - .map(|raqe| search(raqe, costs, seed, &mut accuracy)) + .map(|raqe| { + search( + raqe, + costs, + seed, + allow_undeployable_families, + &mut accuracy, + ) + }) .collect(); let unservable: Vec<_> = searches .iter() @@ -95,28 +106,13 @@ pub fn search( raqe: &Raqe, costs: &[AtomicCostEntry], seed: u64, - accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, -) -> RaqeSearch { - search_variants( - raqe, - raqe.capability.deployable_families(), - costs, - seed, - accuracy, - ) -} - -/// [`search`] over the given variants. -fn search_variants<'a>( - raqe: &Raqe, - variants: impl IntoIterator, - costs: &[AtomicCostEntry], - seed: u64, + allow_undeployable_families: bool, mut accuracy: impl FnMut(&Raqe, &AtomicCostEntry) -> Option, ) -> RaqeSearch { let started = Instant::now(); - let grids: Vec = variants - .into_iter() + let grids: Vec = raqe + .capability + .candidate_families(allow_undeployable_families) .filter_map(|variant| Grid::new(variant, costs)) .collect(); @@ -400,7 +396,7 @@ mod tests { // Needs rows * cols >= 1000: rows=3 cols=400 (1200 counters) is the // smallest; rows=2 cols=800 and rows=3 cols=800 also pass. let r = raqe("r", 3_600_000, 60_000, 1.0 / 1_000.0); - let found = search(&r, &costs, 7, table_accuracy); + let found = search(&r, &costs, 7, false, table_accuracy); assert_eq!(found.selected, Some(cheapest_feasible(&r, &costs))); } @@ -410,7 +406,7 @@ mod tests { let cols: Vec = (0..8).map(|i| 64 << i).collect(); let costs = grid(&rows, &cols); let r = raqe("r", 3_600_000, 60_000, 1.0 / 2_000.0); - let found = search(&r, &costs, 42, table_accuracy); + let found = search(&r, &costs, 42, false, table_accuracy); assert!(found.selected.is_some()); assert!( found.probes.len() < costs.len(), @@ -428,7 +424,7 @@ mod tests { raqe("a", 3_600_000, 60_000, 0.01), raqe("b", 3_600_000, 60_000, 0.01), ]; - let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); + let plan = plan(&raqes, &costs, 1, false, table_accuracy).unwrap(); assert_eq!(plan.deployments.len(), 2); assert_eq!(plan.deployments[0], plan.deployments[1]); assert_eq!(plan.mapping, vec![0, 1]); @@ -453,7 +449,7 @@ mod tests { raqe("tiled", 3_600_000, 60_000, 0.01), raqe("gcd", 3_600_000, 280_000, 0.01), ]; - let plan = plan(&raqes, &costs, 1, table_accuracy).unwrap(); + let plan = plan(&raqes, &costs, 1, false, table_accuracy).unwrap(); assert_eq!(window_adapter(&raqes[0]), (3_600_000, 60_000)); assert_eq!(window_adapter(&raqes[1]), (3_600_000, 40_000)); for (r, d) in raqes.iter().zip(&plan.deployments) { @@ -469,7 +465,7 @@ mod tests { raqe("too_strict", 3_600_000, 60_000, 1e-9), ]; assert_eq!( - plan(&raqes, &costs, 1, table_accuracy).unwrap_err(), + plan(&raqes, &costs, 1, false, table_accuracy).unwrap_err(), vec!["too_strict".to_string()] ); } @@ -478,7 +474,7 @@ mod tests { fn missing_measurement_is_infeasible() { let costs = grid(&[2, 3], &[256, 512]); let r = raqe("r", 3_600_000, 60_000, 0.01); - let found = search(&r, &costs, 1, |_, _| None); + let found = search(&r, &costs, 1, false, |_, _| None); assert_eq!(found.selected, None); assert_eq!(found.probes.len(), costs.len()); } @@ -510,7 +506,7 @@ mod tests { let costs: Vec<_> = (1..=10).map(kll).collect(); let r = quantile_raqe(1.0 / 6.0); for seed in 0..20 { - let found = search(&r, &costs, seed, table_accuracy); + let found = search(&r, &costs, seed, false, table_accuracy); let ks: Vec = found.probes.iter().map(|&i| i as u64 + 1).collect(); assert_eq!(found.selected, Some(5), "seed {seed}: {ks:?}"); let s = ks[0]; @@ -525,20 +521,14 @@ mod tests { #[test] fn chooses_the_cheaper_variant() { - // Both top-k families, though only one is deployable today. + // countsketch-heap isn't deployable, so this needs the study flag. let mut costs = grid(&[2, 3], &[256, 512]); let mut cs = cms(2, 256); cs.sketch = "countsketch-heap-topk-fastpath-vector2d".into(); cs.mem_bytes_per_instance = 100.0; costs.push(cs); let r = raqe("r", 3_600_000, 60_000, 0.01); - let found = search_variants( - &r, - r.capability.families().iter().copied(), - &costs, - 3, - table_accuracy, - ); + let found = search(&r, &costs, 3, true, table_accuracy); assert_eq!(found.selected, Some(costs.len() - 1)); } @@ -556,7 +546,7 @@ mod tests { accuracy_direction: AccuracyDirection::HigherIsBetter, ..quantile_raqe(0.95) }; - let found = search(&r, &costs, 11, table_accuracy); + let found = search(&r, &costs, 11, false, table_accuracy); assert_eq!(found.selected, Some(4)); // k = 5: 0.95 } diff --git a/rqe-optimizer/src/candidates.rs b/rqe-optimizer/src/candidates.rs index 9f27e7b5..2a4db293 100644 --- a/rqe-optimizer/src/candidates.rs +++ b/rqe-optimizer/src/candidates.rs @@ -1,10 +1,7 @@ //! Candidate generation and eligibility (§3). use crate::analytical_cost_model::merge_memory_per_group; -use crate::{ - AtomicCostEntry, Capability, Deployment, LabelSet, Millis, Raqe, WorkloadFacts, - DEPLOYABLE_FAMILIES, -}; +use crate::{AtomicCostEntry, Capability, Deployment, LabelSet, Millis, Raqe, WorkloadFacts}; use std::collections::{BTreeMap, BTreeSet}; pub fn gcd(a: Millis, b: Millis) -> Millis { @@ -58,8 +55,8 @@ fn candidate_deployments( let capability = group[0].capability; let windows = group .iter() - .flat_map(|r| divisors(r.lookback_ms)) - .filter(|window_ms| window_ms.is_multiple_of(scrape_interval_ms)) + .flat_map(|r| divisors(r.lookback_ms / scrape_interval_ms)) + .map(|scrapes| scrapes * scrape_interval_ms) .collect::>(); let mut deployments = Vec::new(); for window_ms in windows { @@ -73,9 +70,9 @@ fn candidate_deployments( .map(|r| gcd(window_ms, r.interval_ms)), ); for config in costs.iter().filter(|c| { - let sketch = c.sketch.as_str(); - capability.families().contains(&sketch) - && (allow_undeployable_families || DEPLOYABLE_FAMILIES.contains(&sketch)) + capability + .candidate_families(allow_undeployable_families) + .any(|family| family == c.sketch) }) { for &slide_ms in &slides { if slide_ms.is_multiple_of(scrape_interval_ms) { @@ -95,9 +92,7 @@ fn candidate_deployments( deployments } -/// Only [`DEPLOYABLE_FAMILIES`] become candidates unless -/// `allow_undeployable_families`, which plans with every measured family -/// (for studies; the plan can't be deployed). +/// Configurations come from [`Capability::candidate_families`]. pub fn build_all_candidates( raqes: &[Raqe], costs: &[AtomicCostEntry], @@ -148,8 +143,8 @@ pub fn build_all_candidates_unpruned( /// Remove a candidate only when another candidate can replace it in every /// mapping without making any modeled objective worse. /// -/// This comparison is deliberately local to a (capability, metric, grouping) -/// group, where `card(G)`, arrival rate and query output size are common +/// This comparison is deliberately local to a (capability, metric, +/// spatial_filter, grouping) group, where `card(G)`, arrival rate and query output size are common /// multipliers. So it compares ingest CPU and memory per group, then latency /// (merge and query CPU), merge memory and stored memory for each RAQE the /// dominated candidate can serve. diff --git a/rqe-optimizer/src/lib.rs b/rqe-optimizer/src/lib.rs index 32946a0c..2561194b 100644 --- a/rqe-optimizer/src/lib.rs +++ b/rqe-optimizer/src/lib.rs @@ -46,6 +46,16 @@ pub enum Capability { } impl Capability { + pub const ALL: [Capability; 7] = [ + Capability::SumOrCount, + Capability::Min, + Capability::Max, + Capability::RateOrIncrease, + Capability::Quantile, + Capability::Cardinality, + Capability::TopK, + ]; + /// `AtomicCostEntry.sketch` values serving this capability. These are /// sketch-bench *variant* strings, not algorithm names: fastpath and /// regularpath have different cost profiles, so `"cms"` would be @@ -75,13 +85,17 @@ impl Capability { } } - /// The subset of [`Capability::families`] in [`DEPLOYABLE_FAMILIES`]: - /// the only variants that become candidates. - pub fn deployable_families(self) -> impl Iterator { - self.families() - .iter() - .copied() - .filter(|family| DEPLOYABLE_FAMILIES.contains(family)) + /// The variants the MILP and AutoSketch plan with: the subset of + /// [`Capability::families`] in [`DEPLOYABLE_FAMILIES`], or all of them + /// when `allow_undeployable_families` (for studies; the plan can't be + /// deployed). + pub fn candidate_families( + self, + allow_undeployable_families: bool, + ) -> impl Iterator { + self.families().iter().copied().filter(move |family| { + allow_undeployable_families || DEPLOYABLE_FAMILIES.contains(family) + }) } } @@ -126,6 +140,9 @@ pub fn validate_facts(raqes: &[Raqe], facts: &WorkloadFacts) -> Result<(), Vec Result<(), Vec