Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion asap-planner-rs/src/optimizer/aqe_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ mod tests {
];
let aqes = extract_aqes(&rqes, &empty_schema());
assert_eq!(aqes.len(), 1);
// f_a = sum of rates (total query load for the MIP objective)
// query_frequency_hz = sum of rates (total query load for the MIP objective)
let expected_freq = 1.0 / 60.0 + 1.0 / 30.0;
assert!((aqes[0].query_frequency_hz - expected_freq).abs() < 1e-9);
// min_t and gcd_t used for windowing constraints
Expand Down
71 changes: 40 additions & 31 deletions asap-planner-rs/src/optimizer/cost_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,72 +64,81 @@ impl Default for CostWeights {

/// IngestCost(g): steady-state cost rate of keeping `candidate` deployed,
/// independent of which AQEs query it (facility-location requirement).
/// `rho_g` is the arrival rate (items/sec) for this config's metric+filter.
/// `arrival_rate_hz` is the arrival rate (items/sec) for this config's metric+filter.
pub fn ingest_cost(
candidate: &CandidateConfig,
rho_g: f64,
arrival_rate_hz: f64,
costs: &AtomicCosts,
weights: &CostWeights,
) -> f64 {
let Some(g) = &candidate.config else {
let Some(agg_config) = &candidate.config else {
return 0.0; // EXACT: no streaming config deployed.
};

// N(s,g) = 1 if subpopulation_aware else N_g (distinct label-group count).
// N_g isn't profiled yet (needs Prometheus series-count data) — use 1 as a
// placeholder; both branches collapse to the same value until that lands.
let n = 1.0_f64;
// Subpopulation count: 1 if subpopulation_aware else the distinct
// label-group count for this config. The label-group count isn't profiled
// yet (needs Prometheus series-count data) — use 1 as a placeholder; both
// branches collapse to the same value until that lands.
let subpopulation_count = 1.0_f64;

// Defensive floor: slide_interval is a plain u64 on a widely-shared struct;
// guard against div-by-zero producing `inf` and poisoning cost comparisons.
let n_concurrent = match g.window_type {
let n_concurrent = match agg_config.window_type {
WindowType::Tumbling => 1.0,
WindowType::Sliding => (g.window_size as f64 / g.slide_interval.max(1) as f64).ceil(),
WindowType::Sliding => {
(agg_config.window_size as f64 / agg_config.slide_interval.max(1) as f64).ceil()
}
};

let mem_active = n_concurrent * n * costs.mem_bytes_per_instance;
let mem_retain = match g.window_type {
WindowType::Tumbling => candidate.n_windows as f64 * n * costs.mem_bytes_per_instance,
let mem_active = n_concurrent * subpopulation_count * costs.mem_bytes_per_instance;
let mem_retain = match agg_config.window_type {
WindowType::Tumbling => {
candidate.n_windows as f64 * subpopulation_count * costs.mem_bytes_per_instance
}
WindowType::Sliding => 0.0, // already counted in mem_active's concurrent windows
};

let cpu_ingest = match g.window_type {
WindowType::Tumbling => rho_g * costs.insert_cpu_secs,
WindowType::Sliding => rho_g * n_concurrent * costs.insert_cpu_secs,
let cpu_ingest = match agg_config.window_type {
WindowType::Tumbling => arrival_rate_hz * costs.insert_cpu_secs,
WindowType::Sliding => arrival_rate_hz * n_concurrent * costs.insert_cpu_secs,
};

weights.ingest_mem * (mem_active + mem_retain) + weights.ingest_cpu * cpu_ingest
}

/// QueryCost(a,g): cost of answering one query for `a` from `candidate`.
/// QueryCost(a,g): cost of answering one query for `aqe` from `candidate`.
pub fn query_cost(
_a: &AQE,
_aqe: &AQE,
candidate: &CandidateConfig,
costs: &AtomicCosts,
weights: &CostWeights,
) -> f64 {
let Some(g) = &candidate.config else {
let Some(agg_config) = &candidate.config else {
return costs.exact_query_cpu_secs * weights.query_cpu; // EXACT: raw query at query time.
};

let n = 1.0_f64; // N(s,g); see ingest_cost comment.
let props = sketch_properties(g.aggregation_type);
// Subpopulation count; see ingest_cost comment.
let subpopulation_count = 1.0_f64;
let props = sketch_properties(agg_config.aggregation_type);

let (cpu, mem) = match &candidate.query_method {
QueryMethod::Direct => (n * costs.query_cpu_secs, n * costs.mem_bytes_per_instance),
QueryMethod::Direct => (
subpopulation_count * costs.query_cpu_secs,
subpopulation_count * costs.mem_bytes_per_instance,
),
QueryMethod::Merge { num_windows } => {
debug_assert!(props.mergeable);
let merges = (*num_windows).saturating_sub(1) as f64;
(
n * (merges * costs.merge_cpu_secs + costs.query_cpu_secs),
*num_windows as f64 * n * costs.mem_bytes_per_instance,
subpopulation_count * (merges * costs.merge_cpu_secs + costs.query_cpu_secs),
*num_windows as f64 * subpopulation_count * costs.mem_bytes_per_instance,
)
}
QueryMethod::Subtract => {
debug_assert!(props.subtractable);
(
n * (costs.subtract_cpu_secs + costs.query_cpu_secs),
2.0 * n * costs.mem_bytes_per_instance,
subpopulation_count * (costs.subtract_cpu_secs + costs.query_cpu_secs),
2.0 * subpopulation_count * costs.mem_bytes_per_instance,
)
}
// candidate_gen only ever pairs Exact with config=None, already handled above.
Expand All @@ -141,18 +150,18 @@ pub fn query_cost(
weights.query_cpu * cpu + weights.query_mem * mem
}

/// Total cost rate contributed by assigning AQE `a` (with frequency `f_a` =
/// `a.query_frequency_hz`) to `candidate`: IngestCost(g) + f_a * QueryCost(a,g).
/// Total cost rate contributed by assigning AQE `aqe` (with frequency
/// `aqe.query_frequency_hz`) to `candidate`: IngestCost(g) + frequency * QueryCost(a,g).
/// This is the per-(a,g) term the greedy/MIP solver minimizes.
pub fn total_cost_rate(
a: &AQE,
aqe: &AQE,
candidate: &CandidateConfig,
rho_g: f64,
arrival_rate_hz: f64,
costs: &AtomicCosts,
weights: &CostWeights,
) -> f64 {
ingest_cost(candidate, rho_g, costs, weights)
+ a.query_frequency_hz * query_cost(a, candidate, costs, weights)
ingest_cost(candidate, arrival_rate_hz, costs, weights)
+ aqe.query_frequency_hz * query_cost(aqe, candidate, costs, weights)
}

#[cfg(test)]
Expand Down
8 changes: 4 additions & 4 deletions asap-planner-rs/src/optimizer/greedy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ use super::solution::{AQEAssignment, OptimizerSolution, AQE};
/// two AQEs could share one. The Phase 3 MIP finds sharing opportunities; this
/// is the v1 baseline.
///
/// `rho_g` is the per-item arrival rate used for every candidate's IngestCost.
/// `arrival_rate_hz` is the per-item arrival rate used for every candidate's IngestCost.
/// Real per-config rates need Prometheus scrape-rate × series-count data,
/// which isn't wired up yet — a single placeholder value is applied uniformly.
pub fn greedy_assign(
aqes: Vec<AQE>,
scrape_interval_secs: u64,
rho_g: f64,
arrival_rate_hz: f64,
costs: &AtomicCosts,
weights: &CostWeights,
) -> OptimizerSolution {
Expand All @@ -35,15 +35,15 @@ pub fn greedy_assign(
let best = candidates
.into_iter()
.map(|c| {
let cost = total_cost_rate(&aqe, &c, rho_g, costs, weights);
let cost = total_cost_rate(&aqe, &c, arrival_rate_hz, costs, weights);
(c, cost)
})
// total_cmp (not partial_cmp().unwrap()) so a stray NaN cost can't panic.
.min_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(c, _)| c)
.expect("enumerate_candidates always returns at least the EXACT fallback");

let ingest = ingest_cost(&best, rho_g, costs, weights);
let ingest = ingest_cost(&best, arrival_rate_hz, costs, weights);
let query_rate = aqe.query_frequency_hz * query_cost(&aqe, &best, costs, weights);
let query_method = best.query_method.clone();

Expand Down
10 changes: 5 additions & 5 deletions asap-planner-rs/src/optimizer/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,20 @@ pub fn run_all_exact_pipeline(
/// No cross-AQE sharing — every deployed sketch serves exactly one AQE, even
/// if two AQEs could share one. The Phase 3 MIP finds sharing opportunities.
///
/// `rho_g` is a placeholder arrival rate applied uniformly to every candidate's
/// IngestCost; real per-config rates need Prometheus scrape-rate × series-count
/// data, which isn't wired up yet (see implementation plan TODOs).
/// `arrival_rate_hz` is a placeholder arrival rate applied uniformly to every
/// candidate's IngestCost; real per-config rates need Prometheus scrape-rate ×
/// series-count data, which isn't wired up yet (see implementation plan TODOs).
pub fn run_greedy_pipeline(
config: &ControllerConfig,
schema: &PromQLSchema,
scrape_interval_secs: u64,
rho_g: f64,
arrival_rate_hz: f64,
) -> (StreamingConfig, InferenceConfig) {
run_pipeline(config, schema, "greedy", |aqes| {
greedy_assign(
aqes,
scrape_interval_secs,
rho_g,
arrival_rate_hz,
&AtomicCosts::default(),
&CostWeights::default(),
)
Expand Down
5 changes: 2 additions & 3 deletions asap-planner-rs/src/optimizer/solution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub struct AQE {
/// Preserved for use by the translator when building InferenceConfig.
pub query_strings: Vec<String>,

/// Query frequency in Hz: f_a (this field) = Σ_{r ∈ R_a} 1/T_r.
/// Query frequency in Hz: this field = Σ_{r ∈ R_a} 1/T_r.
/// Used in the MIP objective to convert per-query QueryCost into a cost
/// rate (cost/sec) commensurate with the continuously-accruing IngestCost.
/// Represents the total query load from all dashboards independently
Expand Down Expand Up @@ -70,8 +70,7 @@ pub struct AQEAssignment {
/// How this AQE's answer is derived from the assigned config.
pub query_method: QueryMethod,

/// Estimated cost rate for this assignment: f_a * QueryCost(a, g), where
/// f_a is `aqe.query_frequency_hz`.
/// Estimated cost rate for this assignment: QueryCost(a, g) * `aqe.query_frequency_hz`.
/// Zero for Exact assignments (IngestCost is also zero).
pub estimated_query_cost_per_sec: f64,
}
Expand Down
Loading