Skip to content

Commit acebd27

Browse files
feat(planner): build rqe-optimizer Raqes and facts from the workload config (#798)
* feat(planner): build rqe-optimizer Raqes and facts from the workload config Converts the workload's optimizer items into sketch-bench `Raqe`s (one per query occurrence), loads per-metric workload facts, and solves with `rqe_optimizer::milp::minimize`. `asap-optimizer-cli --milp` prints the chosen deployments and plan cost; it writes no configs yet. `AtomicCostEntry` is now rqe-optimizer's type instead of a local copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(planner): harden MILP inputs and CLI per review - Raqe ids carry the item index, so items differing only in T or SLA get distinct ids. - `OptimizerItem.occurrences` counts merged leaves exactly; `query_frequency_hz` is derived from it. - An item with more than one statistic is a `MilpError`, not a panic. - Eligibility is computed once per Raqe for the debug log and the unservable check; the item sort is total. - The MILP cost table loader rejects non-finite or negative rows; `--w-cpu`/`--w-mem` must be finite and >= 0. - `--label-set-facts` conflicts with `--milp`; missing `metrics:` hints report `MissingMetricHints`; the MILP path warns on default SLAs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(planner): accept accuracy at the SLA boundary; reject an all-zero objective - Accuracy tolerances get a 1e-9 slack so `1 - sla` rounding doesn't reject a cost row measured exactly at the boundary. - `asap-optimizer-cli --milp` rejects `--w-cpu`/`--w-mem` both 0, which would make the solver's pick arbitrary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3a27281 commit acebd27

14 files changed

Lines changed: 921 additions & 157 deletions

‎Cargo.lock‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎asap-planner-rs/src/bin/optimizer_cli.rs‎

Lines changed: 161 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
use std::path::PathBuf;
88

99
use asap_planner::optimizer::{
10-
load_optional_selected_atomic_cost_table, run_greedy_pipeline, AtomicCostTable, LabelSetFacts,
10+
build_milp_workload, load_flat_atomic_cost_table, load_optional_selected_atomic_cost_table,
11+
load_workload_facts, run_greedy_pipeline, solve_milp, AtomicCostTable, LabelSetFacts,
12+
LabelSetFactsError,
1113
};
1214
use asap_planner::ControllerConfig;
1315
use clap::Parser;
16+
use rqe_optimizer::milp::Objective;
1417

1518
#[derive(Parser, Debug)]
1619
#[command(
@@ -27,26 +30,57 @@ struct Args {
2730
#[arg(long = "data-ingestion-interval-ms", value_parser = clap::value_parser!(u64).range(1..))]
2831
data_ingestion_interval_ms: u64,
2932

30-
/// YAML label-set facts: `series_count` per (metric, spatial filter) and
31-
/// `cardinality` per (metric, spatial filter, grouping labels).
32-
#[arg(long = "label-set-facts")]
33-
label_set_facts: PathBuf,
34-
35-
/// Path to the versioned atomic-cost document sketch-bench's `atomic-costs`
36-
/// subcommand exports. Requires --atomic-cost-workload to select exactly
37-
/// one measured workload profile. Omitted: every
38-
/// benchmarked-family candidate (CMS/HLL/KLL) is dropped, since there is
39-
/// no data to cost it at — only trivial accumulators and EXACT remain
40-
/// selectable.
41-
#[arg(long = "atomic-costs")]
33+
/// Greedy only. YAML label-set facts: `series_count` per (metric, spatial
34+
/// filter) and `cardinality` per (metric, spatial filter, grouping labels).
35+
#[arg(
36+
long = "label-set-facts",
37+
required_unless_present = "milp",
38+
conflicts_with = "milp"
39+
)]
40+
label_set_facts: Option<PathBuf>,
41+
42+
/// Greedy: the versioned atomic-cost document sketch-bench's
43+
/// `atomic-costs` subcommand exports; requires --atomic-cost-workload.
44+
/// Omitted: every benchmarked-family candidate (CMS/HLL/KLL) is dropped,
45+
/// leaving only trivial accumulators and EXACT.
46+
/// MILP: the flat cost table `export_rqe_optimizer_costs.sh` writes
47+
/// (`rqe_atomic_costs.json`); required.
48+
#[arg(long = "atomic-costs", required_if_eq("milp", "true"))]
4249
atomic_costs: Option<PathBuf>,
4350

44-
/// JSON `profiles[].workload` value copied from the sketch-bench atomic-cost
45-
/// document. This makes the empirical workload profile explicit and avoids
46-
/// mixing costs from different traces or time windows.
47-
#[arg(long = "atomic-cost-workload", requires = "atomic_costs")]
51+
/// Greedy only. JSON `profiles[].workload` value copied from the
52+
/// sketch-bench atomic-cost document. This makes the empirical workload
53+
/// profile explicit and avoids mixing costs from different traces or time
54+
/// windows.
55+
#[arg(
56+
long = "atomic-cost-workload",
57+
requires = "atomic_costs",
58+
conflicts_with = "milp"
59+
)]
4860
atomic_cost_workload: Option<PathBuf>,
4961

62+
/// Plan with sketch-bench's rqe-optimizer MILP and print the plan; writes
63+
/// no configs yet.
64+
#[arg(long)]
65+
milp: bool,
66+
67+
/// MILP only. YAML workload facts: per metric, `cardinality` per label
68+
/// set, including the set of all its labels (the series count).
69+
#[arg(
70+
long = "workload-facts",
71+
required_if_eq("milp", "true"),
72+
requires = "milp"
73+
)]
74+
workload_facts: Option<PathBuf>,
75+
76+
/// MILP only. Objective weight on CPU-sec/sec. Default: rqe-optimizer's.
77+
#[arg(long = "w-cpu", requires = "milp", value_parser = parse_weight)]
78+
w_cpu: Option<f64>,
79+
80+
/// MILP only. Objective weight on memory GiB. Default: rqe-optimizer's.
81+
#[arg(long = "w-mem", requires = "milp", value_parser = parse_weight)]
82+
w_mem: Option<f64>,
83+
5084
#[arg(short, long, action = clap::ArgAction::Count)]
5185
verbose: u8,
5286
}
@@ -64,7 +98,14 @@ fn main() -> anyhow::Result<()> {
6498

6599
let yaml_str = std::fs::read_to_string(&args.input_config)?;
66100
let config: ControllerConfig = serde_yaml::from_str(&yaml_str)?;
67-
let facts = LabelSetFacts::from_path(&args.label_set_facts)?;
101+
if args.milp {
102+
return run_milp(&args, &config);
103+
}
104+
let facts = LabelSetFacts::from_path(
105+
args.label_set_facts
106+
.as_deref()
107+
.expect("clap requires --label-set-facts without --milp"),
108+
)?;
68109

69110
let atomic_cost_table = match load_optional_selected_atomic_cost_table(
70111
args.atomic_costs.as_deref(),
@@ -108,3 +149,105 @@ fn main() -> anyhow::Result<()> {
108149

109150
Ok(())
110151
}
152+
153+
/// Objective weights must be finite and non-negative: a negative weight
154+
/// rewards cost, and NaN poisons every coefficient.
155+
fn parse_weight(s: &str) -> Result<f64, String> {
156+
match s.parse::<f64>() {
157+
Ok(w) if w.is_finite() && w >= 0.0 => Ok(w),
158+
Ok(w) => Err(format!("must be finite and >= 0, got {w}")),
159+
Err(e) => Err(e.to_string()),
160+
}
161+
}
162+
163+
fn run_milp(args: &Args, config: &ControllerConfig) -> anyhow::Result<()> {
164+
config.warn_default_slas();
165+
let Some(hints) = config.metrics.as_deref() else {
166+
return Err(LabelSetFactsError::MissingMetricHints.into());
167+
};
168+
let facts = load_workload_facts(
169+
args.workload_facts
170+
.as_deref()
171+
.expect("clap requires --workload-facts with --milp"),
172+
hints,
173+
args.data_ingestion_interval_ms,
174+
)?;
175+
let costs = load_flat_atomic_cost_table(
176+
args.atomic_costs
177+
.as_deref()
178+
.expect("clap requires --atomic-costs with --milp"),
179+
)?;
180+
let Objective::AUCCost { w_cpu, w_mem } = Objective::default();
181+
let (w_cpu, w_mem) = (args.w_cpu.unwrap_or(w_cpu), args.w_mem.unwrap_or(w_mem));
182+
// All-zero weights make every plan cost 0, so the solver's pick is arbitrary.
183+
anyhow::ensure!(
184+
w_cpu > 0.0 || w_mem > 0.0,
185+
"--w-cpu and --w-mem are both 0; at least one must be positive"
186+
);
187+
let objective = Objective::AUCCost { w_cpu, w_mem };
188+
tracing::debug!(?objective, cost_rows = costs.len(), "milp: inputs loaded");
189+
190+
let workload = build_milp_workload(config, &facts, args.data_ingestion_interval_ms)?;
191+
let (deployments, solution) = solve_milp(&workload, &facts, &costs, objective)?;
192+
193+
let mut active: Vec<usize> = solution.mapping.clone();
194+
active.sort();
195+
active.dedup();
196+
println!("=== Deployments: {} ===", active.len());
197+
for &d in &active {
198+
let dep = &deployments[d];
199+
println!(
200+
" [{d}] {:?} {} config={} metric={} grouping={:?} window={}ms slide={}ms",
201+
dep.capability,
202+
dep.config.sketch,
203+
dep.config.sketch_config,
204+
dep.metric,
205+
dep.grouping_labels,
206+
dep.window_ms,
207+
dep.slide_ms,
208+
);
209+
}
210+
println!("\n=== Raqes: {} ===", workload.raqes.len());
211+
for ((raqe, &d), latency_ms) in workload
212+
.raqes
213+
.iter()
214+
.zip(&solution.mapping)
215+
.zip(&solution.plan_cost.query_latency_ms)
216+
{
217+
println!(" {} -> [{d}] latency={latency_ms:.3e}ms", raqe.id);
218+
}
219+
let cost = &solution.plan_cost;
220+
println!(
221+
"\nobjective={:.6e} cpu={:.6e} cpu-sec/sec memory={:.3} MB",
222+
objective.value(cost),
223+
cost.cpu_secs_per_sec(),
224+
cost.memory_bytes() / 1e6,
225+
);
226+
for (phase, c) in [
227+
("ingest", &cost.ingest),
228+
("merge", &cost.merge),
229+
("query", &cost.query),
230+
("storage", &cost.storage),
231+
] {
232+
println!(
233+
" {phase}: cpu={:.6e} cpu-sec/sec memory={:.3} MB",
234+
c.cpu_secs_per_sec,
235+
c.memory_bytes / 1e6
236+
);
237+
}
238+
Ok(())
239+
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::parse_weight;
244+
245+
#[test]
246+
fn weights_must_be_finite_and_non_negative() {
247+
assert_eq!(parse_weight("0.5"), Ok(0.5));
248+
assert_eq!(parse_weight("0"), Ok(0.0));
249+
for bad in ["-1", "NaN", "inf", "x"] {
250+
assert!(parse_weight(bad).is_err(), "{bad}");
251+
}
252+
}
253+
}

‎asap-planner-rs/src/optimizer/aqe_extractor.rs‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ pub fn extract_aqes(
6767
metric_schema: &PromQLSchema,
6868
scrape_interval_ms: u64,
6969
) -> Result<Vec<OptimizerItem>, OptimizerError> {
70-
let mut acc: HashMap<OptimizerItemKey, (QueryRequirements, Vec<String>, f64)> = HashMap::new();
70+
let mut acc: HashMap<OptimizerItemKey, (QueryRequirements, Vec<String>, usize)> =
71+
HashMap::new();
7172

7273
for rqe in rqes {
7374
if rqe.t_repeat_ms == 0 {
@@ -82,13 +83,11 @@ pub fn extract_aqes(
8283
match extract_requirements(&leaf, metric_schema, scrape_interval_ms) {
8384
Ok(req) => {
8485
let key = OptimizerItemKey::from_rqe(&req, rqe);
85-
let entry = acc.entry(key).or_insert_with(|| (req, Vec::new(), 0.0));
86+
let entry = acc.entry(key).or_insert_with(|| (req, Vec::new(), 0));
8687
if !entry.1.contains(&leaf) {
8788
entry.1.push(leaf);
8889
}
89-
// query_frequency_hz must stay in Hz (queries per real second)
90-
// regardless of t_repeat_ms's internal unit — 1000.0 / ms, not 1.0 / ms.
91-
entry.2 += 1000.0 / rqe.t_repeat_ms as f64;
90+
entry.2 += 1;
9291
}
9392
Err(reason) => {
9493
return Err(OptimizerError::UnsupportedLeaf {
@@ -104,10 +103,12 @@ pub fn extract_aqes(
104103
Ok(acc
105104
.into_iter()
106105
.map(
107-
|(key, (requirements, query_strings, query_frequency_hz))| OptimizerItem {
106+
|(key, (requirements, query_strings, occurrences))| OptimizerItem {
108107
requirements,
109108
query_strings,
110-
query_frequency_hz,
109+
// Hz (queries per real second): 1000.0 / ms, not 1.0 / ms.
110+
query_frequency_hz: occurrences as f64 * 1000.0 / key.t_repeat_ms as f64,
111+
occurrences,
111112
t_repeat_ms: key.t_repeat_ms,
112113
accuracy_sla: f64::from_bits(key.accuracy_sla_bits),
113114
latency_sla_ms: key.latency_sla_ms_bits.map(f64::from_bits),

0 commit comments

Comments
 (0)