From 2c231a858756b7c75db50a60a65605958c64e544 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:36:11 +0000 Subject: [PATCH 1/3] feat(asap-aware-mapping)!: price sketch nodes from measured operation costs in Stage 3 PlanSelectionSettings::measured_operation_costs holds the deployment's measured CPU ns per (sketch parameters, update|merge|estimate). A sketch build, pane-chain build, merge or estimate with an entry is priced from it; every other node from the formulas. NodeCost::price_source reports which. Removes the legacy core's PhysicalPlanCostModel, empirical_cost and empirical_resources (and ResourceCalibration); what they did beyond this is tracked in #733. --- .../asap-aware-mapping/src/analytical_cost.rs | 58 +- .../asap-aware-mapping/src/empirical_cost.rs | 740 ----------- .../src/empirical_resources.rs | 202 --- crates/asap-aware-mapping/src/lib.rs | 3 - .../src/physical_plan_cost_model.rs | 1110 ----------------- .../src/stage3_plan_selection.rs | 273 +++- .../measured_operation_costs.rs | 110 ++ .../data/offline-evidence-synthetic.json | 67 - 8 files changed, 375 insertions(+), 2188 deletions(-) delete mode 100644 crates/asap-aware-mapping/src/empirical_cost.rs delete mode 100644 crates/asap-aware-mapping/src/empirical_resources.rs delete mode 100644 crates/asap-aware-mapping/src/physical_plan_cost_model.rs create mode 100644 crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs delete mode 100644 crates/asap-aware-mapping/tests/data/offline-evidence-synthetic.json diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index c62ab78a4..3a029619d 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -1,9 +1,8 @@ //! Analytical, resource-dimensional cost estimates. //! //! This module deliberately keeps CPU work, retained state, and scan I/O -//! separate. They become one planner objective only through an explicit -//! [`ResourceCalibration`]; without that calibration the dimensional -//! estimate is still useful for explanations, but is not silently comparable. +//! separate. Plan selection (Stage 3) turns them into one objective with +//! [`crate::stage3_plan_selection::ResourceUnitCost`]. use std::collections::{HashMap, HashSet}; @@ -148,42 +147,6 @@ pub fn cache_hit_ratios( Ok((resolved.result_hit_ratio, resolved.buffer_hit_ratio)) } -/// Conversion from physical dimensions to one deployment-specific objective. -/// Memory's coefficient means cost units per retained byte over this model's -/// explicit comparison scope; it is not mixed with a rate implicitly. -/// -/// The legacy core's prices ([`crate::physical_plan_cost_model`]); plan -/// selection (Stage 3) prices with -/// [`crate::stage3_plan_selection::ResourceUnitCost`] instead. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ResourceCalibration { - pub cost_per_cpu_op: f64, - pub cost_per_scan_byte: f64, - pub cost_per_retained_byte: f64, - pub version: String, -} - -impl ResourceCalibration { - pub fn validate(&self) -> Result<(), AnalyticalCostError> { - for (name, value) in [ - ("cost_per_cpu_op", self.cost_per_cpu_op), - ("cost_per_scan_byte", self.cost_per_scan_byte), - ("cost_per_retained_byte", self.cost_per_retained_byte), - ] { - if !value.is_finite() || value < 0.0 { - return Err(AnalyticalCostError::InvalidCalibration(name, value)); - } - } - if self.cost_per_cpu_op == 0.0 - && self.cost_per_scan_byte == 0.0 - && self.cost_per_retained_byte == 0.0 - { - return Err(AnalyticalCostError::ZeroCalibration); - } - Ok(()) - } -} - /// An analytical estimate requires CPU work, peak memory, and scanned bytes. /// The shared resource container keeps other dimensions explicitly unknown. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -2142,23 +2105,6 @@ fn validate_promql_series_sample( Ok(()) } -impl ResourceEstimate { - pub fn calibrated_cost( - self, - calibration: &ResourceCalibration, - ) -> Result { - calibration.validate()?; - let value = self.cpu_ops() * calibration.cost_per_cpu_op - + self.scan_bytes() as f64 * calibration.cost_per_scan_byte - + self.peak_memory_bytes() as f64 * calibration.cost_per_retained_byte; - if value.is_finite() { - Ok(value) - } else { - Err(AnalyticalCostError::Overflow) - } - } -} - #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum AnalyticalCostError { #[error("data arrival {0:?} is unsupported by the selected analytical adapter")] diff --git a/crates/asap-aware-mapping/src/empirical_cost.rs b/crates/asap-aware-mapping/src/empirical_cost.rs deleted file mode 100644 index e6273a0b4..000000000 --- a/crates/asap-aware-mapping/src/empirical_cost.rs +++ /dev/null @@ -1,740 +0,0 @@ -//! Offline sketch-bench evidence. Measurements describe a particular dataset, -//! configuration and environment; they are neither runtime feedback nor proofs -//! of an accuracy guarantee. CPU quantities are nanoseconds, never CPU operations. - -use asap_types::post_asap::{SketchAlgorithm, SketchParams}; -use asap_types::pre_asap::AggIntent; -use serde::{Deserialize, Serialize}; - -use crate::cost_model::{CostModel, DefaultCostModel}; -use crate::realization::{accuracy_budget, accuracy_target, default_size_params}; -use crate::replacement::{ReplacementSubDAG, TargetSubDAG}; - -pub const EVIDENCE_SCHEMA_VERSION: u32 = 1; -pub const EVIDENCE_MODEL_VERSION: &str = "empirical-update-cpu-v1"; - -pub use crate::empirical_resources::ResourceMeasurements; -pub use asap_types::resources::Measurement; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DistributionDescriptor { - pub id: String, - pub family: String, - pub sample_count: u64, - pub distinct_count: Option, - /// Generator parameters or trace identity/checksum, including sampling rules. - pub parameters: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EnvironmentDescriptor { - pub id: String, - pub cpu: String, - pub os: String, - pub runtime: String, - pub implementation: String, - pub implementation_version: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MeasurementProvenance { - pub command: String, - pub dataset: String, - pub source_revision: String, - pub repetitions: u32, -} - -/// Observed error on offline ground truth. No confidence or formal guarantee is -/// inferred from these statistics; `metric` defines the meaning of mean/max. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OfflineError { - pub metric: String, - pub mean: Option, - pub max: Option, - pub trials: u32, - pub ground_truth_method: String, - pub query: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OfflineMeasurement { - pub id: String, - pub algorithm: SketchAlgorithm, - pub params: SketchParams, - pub distribution: DistributionDescriptor, - pub environment: EnvironmentDescriptor, - pub measured_at_unix_seconds: u64, - pub valid_until_unix_seconds: u64, - pub provenance: MeasurementProvenance, - pub metrics: ResourceMeasurements, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceArtifact { - pub schema_version: u32, - pub benchmark_version: String, - /// Identity of the normalization/cost interpretation, independent of JSON - /// layout and of the sketch implementation's source revision. - pub model_version: String, - pub records: Vec, -} - -/// The caller explicitly chooses the offline applicability context. Matching -/// all descriptors prevents reusing costs solely because a distribution name -/// or hardware label happens to agree. Time is injected for reproducibility. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceContext { - pub distribution: DistributionDescriptor, - pub environment: EnvironmentDescriptor, - pub now_unix_seconds: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum EvidenceError { - #[error("unsupported offline evidence schema version {0}")] - UnsupportedVersion(u32), - #[error("invalid offline evidence: {0}")] - Invalid(String), - #[error("no measurement for algorithm and exact configuration")] - MissingConfiguration, - #[error("offline distribution or environment is incompatible")] - IncompatibleContext, - #[error("offline measurement is expired or dated in the future")] - Stale, - #[error("multiple applicable measurements; select an unambiguous artifact")] - Ambiguous, -} - -pub struct EmpiricalEvidenceProvider { - artifact: EvidenceArtifact, - context: EvidenceContext, -} - -impl EmpiricalEvidenceProvider { - pub fn new( - artifact: EvidenceArtifact, - context: EvidenceContext, - ) -> Result { - artifact.validate()?; - if artifact.model_version != EVIDENCE_MODEL_VERSION { - return invalid("unsupported offline cost model version"); - } - validate_context(&context.distribution, &context.environment)?; - Ok(Self { artifact, context }) - } - - pub fn artifact(&self) -> &EvidenceArtifact { - &self.artifact - } - pub fn context(&self) -> &EvidenceContext { - &self.context - } - - /// Never interpolates between configurations, distributions, or machines. - /// The returned row includes complete provenance for user explanations. - pub fn lookup( - &self, - algorithm: &SketchAlgorithm, - params: &SketchParams, - ) -> Result<&OfflineMeasurement, EvidenceError> { - let configurations: Vec<_> = self - .artifact - .records - .iter() - .filter(|r| &r.algorithm == algorithm && &r.params == params) - .collect(); - if configurations.is_empty() { - return Err(EvidenceError::MissingConfiguration); - } - let compatible: Vec<_> = configurations - .into_iter() - .filter(|r| { - r.distribution == self.context.distribution - && r.environment == self.context.environment - }) - .collect(); - if compatible.is_empty() { - return Err(EvidenceError::IncompatibleContext); - } - let mut valid = compatible.into_iter().filter(|r| { - r.measured_at_unix_seconds <= self.context.now_unix_seconds - && self.context.now_unix_seconds <= r.valid_until_unix_seconds - }); - let first = valid.next().ok_or(EvidenceError::Stale)?; - if valid.next().is_some() { - return Err(EvidenceError::Ambiguous); - } - Ok(first) - } - - /// Reorders only a fully measured comparison, preserving all candidates. - /// For deployment-specific sizing call `lookup` with those exact params. - pub fn rank_candidates( - &self, - intent: &AggIntent, - candidates: &[SketchAlgorithm], - eps: f64, - delta: f64, - ) -> Vec { - let costs: Option> = candidates - .iter() - .map(|algorithm| { - let params = default_size_params(algorithm.clone(), intent, eps, delta); - self.lookup(algorithm, ¶ms) - .ok()? - .metrics - .resources - .cpu - .update_cpu_ns - .as_ref() - .map(|m| (algorithm.clone(), m.value)) - }) - .collect(); - let Some(mut costs) = costs else { - return candidates.to_vec(); - }; - costs.sort_by(|a, b| a.1.total_cmp(&b.1)); - costs.into_iter().map(|(algorithm, _)| algorithm).collect() - } -} - -/// Standalone adapter for the existing planner boundary. Empirical data changes -/// candidate discovery order; final structural scores retain their documented -/// default meaning. Complete plan benefit estimates require downstream raw and -/// summary physical evidence and are deliberately not invented here. -pub struct EmpiricalCostModel { - pub provider: EmpiricalEvidenceProvider, -} - -impl EmpiricalCostModel { - pub fn new(provider: EmpiricalEvidenceProvider) -> Self { - Self { provider } - } -} - -impl CostModel for EmpiricalCostModel { - fn rank_candidates( - &self, - intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - let Some(accuracy) = accuracy_target(intent) else { - return candidates.to_vec(); - }; - let (eps, delta) = accuracy_budget(&accuracy); - self.provider - .rank_candidates(intent, candidates, eps, delta) - } - - // The default size_params formula retains its formal guarantee. Observed - // offline error is insufficient evidence to shrink a sketch safely. - fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { - DefaultCostModel.estimate_cost(candidate, target) - } -} - -impl EvidenceArtifact { - pub fn validate(&self) -> Result<(), EvidenceError> { - if self.schema_version != EVIDENCE_SCHEMA_VERSION { - return Err(EvidenceError::UnsupportedVersion(self.schema_version)); - } - if self.benchmark_version.trim().is_empty() || self.model_version.trim().is_empty() { - return invalid("missing benchmark or model version"); - } - let mut ids = std::collections::HashSet::new(); - for row in &self.records { - if row.id.trim().is_empty() || !ids.insert(&row.id) { - return invalid("empty or duplicate record id"); - } - validate_context(&row.distribution, &row.environment)?; - let p = &row.provenance; - if [&p.command, &p.dataset, &p.source_revision] - .iter() - .any(|s| s.trim().is_empty()) - || p.repetitions == 0 - { - return invalid("missing measurement provenance"); - } - if row.measured_at_unix_seconds > row.valid_until_unix_seconds { - return invalid("reversed validity interval"); - } - if !valid_params(&row.algorithm, &row.params) { - return invalid("invalid or mismatched sketch parameters"); - } - let m = &row.metrics.resources; - for measurement in [ - &m.cpu.build_cpu_ns, - &m.cpu.update_cpu_ns, - &m.cpu.merge_cpu_ns, - &m.cpu.prepare_cpu_ns, - &m.cpu.read_cpu_ns, - &m.retained_memory_bytes, - &m.peak_memory_bytes, - &m.serialized_bytes, - &m.disk_bytes, - &m.scan_bytes, - ] - .into_iter() - .flatten() - { - if !nonnegative(measurement.value) - || measurement.samples == 0 - || measurement.stddev.is_some_and(|v| !nonnegative(v)) - { - return invalid("invalid measurement or uncertainty"); - } - } - if let Some(error) = &row.error { - if error.metric.trim().is_empty() - || error.ground_truth_method.trim().is_empty() - || error.trials == 0 - || [error.mean, error.max] - .into_iter() - .flatten() - .any(|v| !nonnegative(v)) - { - return invalid("invalid offline error evidence"); - } - } - } - Ok(()) - } -} - -fn invalid(message: &str) -> Result { - Err(EvidenceError::Invalid(message.into())) -} -fn nonnegative(value: f64) -> bool { - value.is_finite() && value >= 0.0 -} - -fn validate_context( - distribution: &DistributionDescriptor, - environment: &EnvironmentDescriptor, -) -> Result<(), EvidenceError> { - if [ - &distribution.id, - &distribution.family, - &environment.id, - &environment.cpu, - &environment.os, - &environment.runtime, - &environment.implementation, - &environment.implementation_version, - ] - .iter() - .any(|s| s.trim().is_empty()) - { - return invalid("missing distribution or environment identity"); - } - if distribution.sample_count == 0 - || distribution - .distinct_count - .is_some_and(|n| n > distribution.sample_count) - || !distribution.parameters.is_object() - { - return invalid("invalid distribution descriptors"); - } - Ok(()) -} - -fn valid_params(algorithm: &SketchAlgorithm, params: &SketchParams) -> bool { - match (algorithm, params) { - ( - SketchAlgorithm::UnivMon, - SketchParams::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - .. - }, - ) => { - *heap_size > 0 - && crate::accuracy::estimators::univmon::hash_independent( - *sketch_rows, - *sketch_cols, - *layers, - ) - } - (SketchAlgorithm::Kll, SketchParams::Kll { k, .. }) - | (SketchAlgorithm::Kmv, SketchParams::Kmv { k, .. }) - | (SketchAlgorithm::Theta, SketchParams::Theta { k }) => *k > 0, - (SketchAlgorithm::Hll, SketchParams::Hll { precision, .. }) => (4..=18).contains(precision), - (SketchAlgorithm::DDSketch, SketchParams::DDSketch { alpha }) => { - alpha.is_finite() && *alpha > 0.0 && *alpha < 1.0 - } - (SketchAlgorithm::Cms, SketchParams::Cms { width, depth }) => *width > 0 && *depth > 0, - // The kernel's shape rules (an odd depth, for the median). - (SketchAlgorithm::CountSketch, SketchParams::CountSketch { width, depth }) => { - count_sketch_builds(*width, *depth) - } - ( - SketchAlgorithm::CmsWithHeap, - SketchParams::CmsWithHeap { - width, - depth, - heap_size, - }, - ) => *width > 0 && *depth > 0 && *heap_size > 0, - ( - SketchAlgorithm::CountSketchWithHeap, - SketchParams::CountSketchWithHeap { - width, - depth, - heap_size, - }, - ) => count_sketch_builds(*width, *depth) && *heap_size > 0, - _ => false, - } -} - -/// Whether `asap_sketchlib`'s CountSketch kernel builds a `width x depth` -/// shape, so evidence for it describes an executable shape. -fn count_sketch_builds(width: u32, depth: u32) -> bool { - asap_sketchlib::WeightedFrequency::check_shape( - asap_sketchlib::FrequencyAlgorithm::CountSketch, - width as usize, - depth as usize, - ) - .is_ok() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::replacement::{realizations_for_intent, Realization}; - use asap_types::types::AccuracyTarget; - - /// The documented synthetic wire-format example remains importable and - /// explicitly identifiable as a test fixture. - #[test] - fn checked_in_synthetic_example_is_valid() { - let artifact: EvidenceArtifact = serde_json::from_str(include_str!( - "../tests/data/offline-evidence-synthetic.json" - )) - .unwrap(); - artifact.validate().unwrap(); - assert!(artifact.records[0] - .environment - .implementation - .contains("SYNTHETIC")); - let schema: serde_json::Value = serde_json::from_str(include_str!( - "../../../docs/develop_docs/offline-sketch-evidence.schema.json" - )) - .unwrap(); - assert_eq!( - schema["properties"]["schema_version"]["const"], - artifact.schema_version - ); - assert_eq!( - schema["properties"]["model_version"]["const"], - EVIDENCE_MODEL_VERSION - ); - } - - /// Newly shared optional dimensions receive the same numeric validation. - #[test] - fn optional_prepare_and_scan_measurements_are_validated() { - for prepare in [true, false] { - let (mut artifact, _, _) = fixture(); - let resources = &mut artifact.records[0].metrics.resources; - let field = if prepare { - &mut resources.cpu.prepare_cpu_ns - } else { - &mut resources.scan_bytes - }; - *field = Some(Measurement { - value: -1.0, - stddev: None, - samples: 1, - method: None, - }); - assert!(artifact.validate().is_err()); - } - } - - fn fixture() -> (EvidenceArtifact, EvidenceContext, AggIntent) { - let distribution = DistributionDescriptor { - id: "unit-test-uniform".into(), - family: "uniform".into(), - sample_count: 1000, - distinct_count: Some(100), - parameters: serde_json::json!({"seed": 7}), - }; - let environment = EnvironmentDescriptor { - id: "unit-test-machine".into(), - cpu: "test CPU".into(), - os: "test OS".into(), - runtime: "test runtime".into(), - implementation: "synthetic test fixture".into(), - implementation_version: "test-v1".into(), - }; - let intent = AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.01), - }; - let records = [ - (SketchAlgorithm::Cms, 20.0), - (SketchAlgorithm::CountSketch, 10.0), - (SketchAlgorithm::UnivMon, 100.0), - ] - .into_iter() - .map(|(algorithm, cost)| OfflineMeasurement { - id: format!("test-{algorithm:?}"), - params: default_size_params(algorithm.clone(), &intent, 0.01, 0.01), - algorithm, - distribution: distribution.clone(), - environment: environment.clone(), - measured_at_unix_seconds: 100, - valid_until_unix_seconds: 200, - provenance: MeasurementProvenance { - command: "unit test fixture; not a measured benchmark".into(), - dataset: "synthetic fixture".into(), - source_revision: "test".into(), - repetitions: 3, - }, - metrics: ResourceMeasurements { - resources: asap_types::resources::MeasuredResources { - cpu: asap_types::resources::MeasuredCpu { - update_cpu_ns: Some(Measurement { - value: cost, - stddev: Some(1.0), - samples: 3, - method: None, - }), - ..Default::default() - }, - ..Default::default() - }, - }, - error: Some(OfflineError { - metric: "mean absolute relative frequency error".into(), - mean: Some(0.001), - max: None, - trials: 3, - ground_truth_method: "test exact counter fixture".into(), - query: serde_json::json!({"kind":"point_frequency"}), - }), - }) - .collect(); - ( - EvidenceArtifact { - schema_version: EVIDENCE_SCHEMA_VERSION, - benchmark_version: "test-fixture-v1".into(), - model_version: "empirical-update-cpu-v1".into(), - records, - }, - EvidenceContext { - distribution, - environment, - now_unix_seconds: 150, - }, - intent, - ) - } - - /// A KLL measurement matches a KLL of the same `k` whatever failure - /// probability either was sized for. - #[test] - fn lookup_ignores_the_kll_failure_probability() { - let (mut artifact, context, _) = fixture(); - artifact.records.truncate(1); - artifact.records[0].algorithm = SketchAlgorithm::Kll; - artifact.records[0].params = SketchParams::Kll { - k: 331, - failure_probability: None, - }; - let provider = EmpiricalEvidenceProvider::new(artifact, context).unwrap(); - assert!(provider - .lookup( - &SketchAlgorithm::Kll, - &SketchParams::Kll { - k: 331, - failure_probability: Some(0.001), - }, - ) - .is_ok()); - } - - /// Real replacement generation follows measured update ranking while keeping - /// every candidate and the same formally sized parameter configurations. - #[test] - fn public_cost_model_changes_replacement_order_without_changing_guarantees() { - let (artifact, context, intent) = fixture(); - let model = - EmpiricalCostModel::new(EmpiricalEvidenceProvider::new(artifact, context).unwrap()); - let default = realizations_for_intent(&intent, &DefaultCostModel); - let measured = realizations_for_intent(&intent, &model); - assert_eq!(default.len(), measured.len()); - let Realization::Sketch(first_default) = &default[0] else { - panic!("expected sketch") - }; - let Realization::Sketch(first_measured) = &measured[0] else { - panic!("expected sketch") - }; - assert_eq!(first_default.algorithm(), &SketchAlgorithm::Cms); - assert_eq!(first_measured.algorithm(), &SketchAlgorithm::CountSketch); - for candidate in &measured { - assert!(default.contains(candidate)); - } - assert_eq!( - model.size_params(SketchAlgorithm::Cms, &intent, 0.001, 0.001), - DefaultCostModel.size_params(SketchAlgorithm::Cms, &intent, 0.001, 0.001) - ); - } - - /// Missing, mismatched and expired evidence preserve the original ranking; - /// a measurement from another configuration is never extrapolated. - #[test] - fn unavailable_evidence_falls_back_with_specific_reasons() { - let (artifact, context, intent) = fixture(); - let candidates = vec![SketchAlgorithm::Cms, SketchAlgorithm::CountSketch]; - for (mut evidence, mut request, expected) in [ - ( - artifact.clone(), - context.clone(), - EvidenceError::MissingConfiguration, - ), - ( - artifact.clone(), - context.clone(), - EvidenceError::IncompatibleContext, - ), - (artifact.clone(), context.clone(), EvidenceError::Stale), - ] { - match expected { - EvidenceError::MissingConfiguration => { - evidence.records.remove(1); - } - EvidenceError::IncompatibleContext => { - request.distribution.parameters = serde_json::json!({"seed":8}); - } - EvidenceError::Stale => { - request.now_unix_seconds = 201; - } - _ => unreachable!(), - } - let provider = EmpiricalEvidenceProvider::new(evidence, request).unwrap(); - assert_eq!( - provider - .lookup(&artifact.records[1].algorithm, &artifact.records[1].params) - .unwrap_err(), - expected - ); - assert_eq!( - provider.rank_candidates(&intent, &candidates, 0.01, 0.01), - candidates - ); - } - let provider = EmpiricalEvidenceProvider::new(artifact, context).unwrap(); - assert_eq!( - provider.rank_candidates(&intent, &candidates, 0.001, 0.01), - candidates - ); - } - - /// Null remains unknown across serialization; zero is accepted only as an - /// explicit valid measurement, and no point-frequency error becomes a bound. - #[test] - fn serialization_preserves_unknown_zero_and_provenance() { - let (mut artifact, context, _) = fixture(); - artifact.records[0].metrics.resources.disk_bytes = Some(Measurement { - value: 0.0, - stddev: None, - samples: 1, - method: None, - }); - let decoded: EvidenceArtifact = - serde_json::from_str(&serde_json::to_string(&artifact).unwrap()).unwrap(); - let provider = EmpiricalEvidenceProvider::new(decoded, context).unwrap(); - let row = provider - .lookup(&artifact.records[0].algorithm, &artifact.records[0].params) - .unwrap(); - assert!(row.metrics.resources.peak_memory_bytes.is_none()); - assert_eq!( - row.metrics.resources.disk_bytes.as_ref().unwrap().value, - 0.0 - ); - assert_eq!(row.provenance, artifact.records[0].provenance); - assert_eq!(row.error.as_ref().unwrap().query["kind"], "point_frequency"); - } - - /// Malformed values, schema versions and ambiguous live records cannot - /// silently become plausible costs. - #[test] - fn invalid_and_ambiguous_artifacts_are_rejected() { - let (artifact, context, _) = fixture(); - let mut bad = artifact.clone(); - bad.schema_version = 2; - assert_eq!(bad.validate(), Err(EvidenceError::UnsupportedVersion(2))); - let mut bad = artifact.clone(); - bad.records[0] - .metrics - .resources - .cpu - .update_cpu_ns - .as_mut() - .unwrap() - .value = f64::NAN; - assert!(bad.validate().is_err()); - let mut bad = artifact.clone(); - bad.records[0].params = SketchParams::Hll { - precision: 14, - relative_error: None, - }; - assert!(bad.validate().is_err()); - let mut bad = artifact.clone(); - bad.records[0].provenance.repetitions = 0; - assert!(bad.validate().is_err()); - let mut duplicate = artifact.records[0].clone(); - duplicate.id = "another-live-measurement".into(); - let mut ambiguous = artifact.clone(); - ambiguous.records.push(duplicate); - let provider = EmpiricalEvidenceProvider::new(ambiguous, context).unwrap(); - assert_eq!( - provider.lookup(&artifact.records[0].algorithm, &artifact.records[0].params), - Err(EvidenceError::Ambiguous) - ); - } - - /// A CountSketch reads the median of its rows, so evidence for an even - /// depth describes no executable shape. - #[test] - fn count_sketch_evidence_requires_an_odd_depth() { - for (depth, valid) in [(5, true), (4, false)] { - assert_eq!( - valid_params( - &SketchAlgorithm::CountSketch, - &SketchParams::CountSketch { width: 64, depth } - ), - valid - ); - assert_eq!( - valid_params( - &SketchAlgorithm::CountSketchWithHeap, - &SketchParams::CountSketchWithHeap { - width: 64, - depth, - heap_size: 8 - } - ), - valid - ); - } - assert!(valid_params( - &SketchAlgorithm::Cms, - &SketchParams::Cms { - width: 64, - depth: 4 - } - )); - } -} diff --git a/crates/asap-aware-mapping/src/empirical_resources.rs b/crates/asap-aware-mapping/src/empirical_resources.rs deleted file mode 100644 index 716392f38..000000000 --- a/crates/asap-aware-mapping/src/empirical_resources.rs +++ /dev/null @@ -1,202 +0,0 @@ -//! Measured resource payloads and compatibility with the v1 benchmark wire format. -//! -//! Physical dimensions live in `asap_types::resources`; flat wire structs below -//! exist only to keep archived artifacts readable and preserve their field names. - -use asap_types::resources::PhysicalResources; -use serde::{Deserialize, Serialize}; - -pub use asap_types::resources::{MeasuredCpu, MeasuredResources, Measurement}; - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(from = "SketchWire", into = "SketchWire")] -pub struct ResourceMeasurements { - pub resources: MeasuredResources, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(from = "ExactWire", into = "ExactWire")] -pub struct ExactResourceMeasurements { - pub resources: MeasuredResources, -} - -// Keep the archived flat v1 schema at the serialization boundary only. New -// optional dimensions are omitted when absent, so legacy snapshots round-trip. -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct SketchWire { - build_cpu_ns: Option, - update_cpu_ns: Option, - merge_cpu_ns: Option, - read_cpu_ns: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - prepare_cpu_ns: Option, - retained_bytes: Option, - peak_bytes: Option, - serialized_bytes: Option, - disk_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - scan_bytes: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExactWire { - empty_build_cpu_ns: Option, - update_cpu_ns: Option, - prepare_cpu_ns: Option, - read_cpu_ns: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - merge_cpu_ns: Option, - retained_bytes: Option, - peak_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - serialized_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - disk_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - scan_bytes: Option, -} - -impl From for ResourceMeasurements { - fn from(w: SketchWire) -> Self { - Self { - resources: PhysicalResources { - cpu: MeasuredCpu { - build_cpu_ns: w.build_cpu_ns, - update_cpu_ns: w.update_cpu_ns, - merge_cpu_ns: w.merge_cpu_ns, - prepare_cpu_ns: w.prepare_cpu_ns, - read_cpu_ns: w.read_cpu_ns, - }, - retained_memory_bytes: w.retained_bytes, - peak_memory_bytes: w.peak_bytes, - serialized_bytes: w.serialized_bytes, - disk_bytes: w.disk_bytes, - scan_bytes: w.scan_bytes, - }, - } - } -} - -impl From for SketchWire { - fn from(value: ResourceMeasurements) -> Self { - let r = value.resources; - Self { - build_cpu_ns: r.cpu.build_cpu_ns, - update_cpu_ns: r.cpu.update_cpu_ns, - merge_cpu_ns: r.cpu.merge_cpu_ns, - prepare_cpu_ns: r.cpu.prepare_cpu_ns, - read_cpu_ns: r.cpu.read_cpu_ns, - retained_bytes: r.retained_memory_bytes, - peak_bytes: r.peak_memory_bytes, - serialized_bytes: r.serialized_bytes, - disk_bytes: r.disk_bytes, - scan_bytes: r.scan_bytes, - } - } -} - -impl From for ExactResourceMeasurements { - fn from(w: ExactWire) -> Self { - Self { - resources: PhysicalResources { - cpu: MeasuredCpu { - build_cpu_ns: w.empty_build_cpu_ns, - update_cpu_ns: w.update_cpu_ns, - merge_cpu_ns: w.merge_cpu_ns, - prepare_cpu_ns: w.prepare_cpu_ns, - read_cpu_ns: w.read_cpu_ns, - }, - retained_memory_bytes: w.retained_bytes, - peak_memory_bytes: w.peak_bytes, - serialized_bytes: w.serialized_bytes, - disk_bytes: w.disk_bytes, - scan_bytes: w.scan_bytes, - }, - } - } -} - -impl From for ExactWire { - fn from(value: ExactResourceMeasurements) -> Self { - let r = value.resources; - Self { - empty_build_cpu_ns: r.cpu.build_cpu_ns, - update_cpu_ns: r.cpu.update_cpu_ns, - merge_cpu_ns: r.cpu.merge_cpu_ns, - prepare_cpu_ns: r.cpu.prepare_cpu_ns, - read_cpu_ns: r.cpu.read_cpu_ns, - retained_bytes: r.retained_memory_bytes, - peak_bytes: r.peak_memory_bytes, - serialized_bytes: r.serialized_bytes, - disk_bytes: r.disk_bytes, - scan_bytes: r.scan_bytes, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn measured(value: f64) -> Option { - Some(Measurement { - value, - stddev: Some(0.5), - samples: 5, - method: Some("test only".into()), - }) - } - - /// The same resource dimensions retain uncertainty through either wire adapter. - #[test] - fn shared_resources_round_trip_without_losing_dimensions() { - let resources = PhysicalResources { - cpu: MeasuredCpu { - build_cpu_ns: measured(1.0), - update_cpu_ns: measured(2.0), - merge_cpu_ns: measured(3.0), - prepare_cpu_ns: measured(4.0), - read_cpu_ns: measured(5.0), - }, - peak_memory_bytes: measured(100.0), - retained_memory_bytes: measured(70.0), - scan_bytes: measured(200.0), - serialized_bytes: measured(40.0), - disk_bytes: measured(4096.0), - }; - let sketch = ResourceMeasurements { - resources: resources.clone(), - }; - let exact = ExactResourceMeasurements { resources }; - assert_eq!( - serde_json::from_value::(serde_json::to_value(&sketch).unwrap()) - .unwrap(), - sketch - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&exact).unwrap() - ) - .unwrap(), - exact - ); - } - - /// Archived names remain accepted, but physical fields use canonical names. - #[test] - fn legacy_fields_map_to_shared_resources() { - let value = serde_json::json!({"empty_build_cpu_ns": measured(3.0), "peak_bytes": measured(100.0), "retained_bytes": measured(70.0)}); - let exact: ExactResourceMeasurements = serde_json::from_value(value).unwrap(); - assert_eq!(exact.resources.cpu.build_cpu_ns, measured(3.0)); - assert_eq!(exact.resources.peak_memory_bytes, measured(100.0)); - assert_eq!(exact.resources.retained_memory_bytes, measured(70.0)); - assert!(exact.resources.scan_bytes.is_none()); - assert!(exact.resources.disk_bytes.is_none()); - assert!( - serde_json::from_value::(serde_json::json!({"cpu_ops": 42})) - .is_err() - ); - } -} diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index a8e2c3954..4db6343a4 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -149,8 +149,6 @@ pub mod analytical_cost; pub mod cost_model; /// The deployment's capabilities: supported summaries and what it retains. pub mod deployment_capabilities; -pub mod empirical_cost; -pub mod empirical_resources; pub mod exact_composition; pub mod explanation; mod function_rules; @@ -159,7 +157,6 @@ pub mod pane_sharing; pub mod pass; pub mod physical_handoff_cost; pub mod physical_operator_statistics; -pub mod physical_plan_cost_model; pub mod query_physical_lowering; pub mod realization; pub mod recurrence; diff --git a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs deleted file mode 100644 index aeda3cb95..000000000 --- a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs +++ /dev/null @@ -1,1110 +0,0 @@ -//! Evidence-backed physical-plan costing at the planner selection handoff. - -use std::{cell::RefCell, rc::Rc}; - -use asap_types::ir::OperatorNode; -use asap_types::post_asap::SketchAlgorithm; -use asap_types::pre_asap::AggIntent; -use asap_types::resources::CacheProfile; - -use crate::analytical_cost::{ - estimate_physical_dag_comparison, AnalyticalCostError, - EvidenceBackedPhysicalDAG as PhysicalDAG, PhysicalDAGComparisonEstimate, - PhysicalDAGEstimateRequest, PhysicalNodeEvidence, ResourceCalibration, -}; -use crate::cost_model::{Cost, CostModel, DefaultCostModel}; -use crate::physical_operator_statistics::ComparisonScope; -use crate::query_physical_lowering::{ - lower_query_physical_dag, PhysicalNodeEvidenceProvider, PhysicalNodeRequest, -}; -use crate::replacement::{Replacement, ReplacementSubDAG, TargetSubDAG}; - -/// One immutable generation of deployment evidence for a planner target. -/// -/// The opaque version lets a provider bind every subsequent query and summary -/// lookup to the same catalog/runtime snapshot. A cost-model instance retains -/// this value for the target, so a caller that needs fresher evidence creates a -/// new model instead of mixing generations in one ranking decision. -#[derive(Debug, Clone, PartialEq)] -pub struct PhysicalEvidenceSnapshot { - pub version: String, - pub scope: ComparisonScope, - pub cache_profile: CacheProfile, - pub storage_io: Option, - pub handoffs: Option, -} - -/// Deployment evidence needed to price one planner alternative. -/// -/// The planner lowers raw queries and logical rewrites itself. A deployment -/// supplies the comparison scope and atomic evidence for each selected query -/// operator. Post-ASAP summary operators need a physical plan provider because their -/// implementation, placement, and retained-state layout are deployment -/// choices; that provider must return the complete summary DAG, including any -/// non-ASAP work kept inside it. -pub trait PlannerPhysicalPlanProvider { - /// Atomically captures the comparison scope and evidence generation. - fn capture_evidence_snapshot( - &self, - target: &TargetSubDAG<'_>, - ) -> Result; - - fn query_node_evidence( - &self, - snapshot: &PhysicalEvidenceSnapshot, - request: PhysicalNodeRequest<'_>, - ) -> Result; - - fn summary_physical_dag( - &self, - snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, - target: &TargetSubDAG<'_>, - ) -> Result; -} - -/// Dimensional comparison retained for explanations and verification. -#[derive(Debug, Clone, PartialEq)] -pub struct PhysicalPlanComparison { - pub resources: PhysicalDAGComparisonEstimate, - pub raw_cost: Cost, - pub candidate_cost: Cost, - pub storage_io: Option<( - crate::storage_io::StorageEstimate, - crate::storage_io::StorageEstimate, - )>, - pub handoffs: Option<( - crate::physical_handoff_cost::PhysicalHandoffEstimate, - crate::physical_handoff_cost::PhysicalHandoffEstimate, - )>, -} - -/// Planner cost model that admits only complete, cheaper physical plans. -/// -/// There is deliberately no structural or compact-formula fallback. Failure -/// to lower either alternative, missing statistics, an unknown physical -/// algorithm, or invalid source/horizon evidence makes the candidate -/// unavailable and leaves the target on its raw path. -pub struct PhysicalPlanCostModel<'a> { - provider: &'a dyn PlannerPhysicalPlanProvider, - calibration: ResourceCalibration, - target_evidence: RefCell>, -} - -struct CachedTargetEvidence { - root: Rc, - consumer_count: usize, - snapshot: PhysicalEvidenceSnapshot, - raw: PhysicalDAG, -} - -impl<'a> PhysicalPlanCostModel<'a> { - pub fn new( - provider: &'a dyn PlannerPhysicalPlanProvider, - calibration: ResourceCalibration, - ) -> Result { - // Exported scores retain base calibration provenance even when the - // objective is priced entirely by supplementary storage operations. - if calibration.version.trim().is_empty() { - return Err(AnalyticalCostError::MissingOrStale( - "resource_calibration.version", - )); - } - // Supplemental storage or handoff pricing may supply a zero-base objective. - match calibration.validate() { - Ok(()) | Err(AnalyticalCostError::ZeroCalibration) => {} - Err(error) => return Err(error), - } - Ok(Self { - provider, - calibration, - target_evidence: RefCell::new(Vec::new()), - }) - } - - fn target_evidence( - &self, - target: &TargetSubDAG<'_>, - ) -> Result<(PhysicalEvidenceSnapshot, PhysicalDAG), AnalyticalCostError> { - if let Some(cached) = self.target_evidence.borrow().iter().find(|cached| { - Rc::ptr_eq(&cached.root, target.root) && cached.consumer_count == target.consumer_count - }) { - return Ok((cached.snapshot.clone(), cached.raw.clone())); - } - - let snapshot = self.provider.capture_evidence_snapshot(target)?; - if snapshot.version.trim().is_empty() { - return Err(AnalyticalCostError::MissingOrStale( - "planner_evidence_snapshot.version", - )); - } - snapshot.scope.validate()?; - let evidence = QueryEvidence { - provider: self.provider, - snapshot: &snapshot, - }; - let raw = lower_query_physical_dag(target.root, &snapshot.scope, &evidence)?; - self.target_evidence - .borrow_mut() - .push(CachedTargetEvidence { - root: Rc::clone(target.root), - consumer_count: target.consumer_count, - snapshot: snapshot.clone(), - raw: raw.clone(), - }); - Ok((snapshot, raw)) - } - - pub fn estimate_candidate( - &self, - candidate: &ReplacementSubDAG, - target: &TargetSubDAG<'_>, - ) -> Result { - let (snapshot, raw) = self.target_evidence(target)?; - // Aggregate cache hit ratios do not identify which independently rounded - // extents issue requests. Do not mix cache-adjusted bytes/CPU with - // uncached request counts until cache-aware extent evidence is available. - if snapshot.storage_io.is_some() - && matches!(&snapshot.cache_profile, CacheProfile::Evidence(_)) - { - return Err(AnalyticalCostError::InvalidCacheEvidence( - "storage operation costs require an explicit no-cache profile; cache-aware extent evidence is unavailable", - )); - } - let scope = &snapshot.scope; - if snapshot.handoffs.is_some() - && matches!(snapshot.cache_profile, CacheProfile::Evidence(_)) - { - // Scope-based handoff multiplicity does not describe which actions - // cache hits skip; do not mix pre-cache byte work with discounted CPU. - return Err(AnalyticalCostError::MissingOrStale( - "cache-aware handoff execution evidence", - )); - } - let evidence = QueryEvidence { - provider: self.provider, - snapshot: &snapshot, - }; - let replacement = match &candidate.replacement { - Replacement::ExactComposition(_) => { - return Err(AnalyticalCostError::UnsupportedCandidate) - } - // A sub-DAG without summary state is the planner's own query - // lowering; anything with summary state is deployment-provided. - Replacement::SubDAG(sub_dag) if !sub_dag.contains_asap() => { - lower_query_physical_dag(sub_dag, scope, &evidence)? - } - Replacement::SubDAG(summary) => self - .provider - .summary_physical_dag(&snapshot, summary, target)?, - }; - let resources = estimate_physical_dag_comparison( - PhysicalDAGEstimateRequest { - nodes: &raw.nodes, - root: &raw.root, - scope, - statistics: &raw, - cache_profile: &snapshot.cache_profile, - }, - PhysicalDAGEstimateRequest { - nodes: &replacement.nodes, - root: &replacement.root, - scope, - statistics: &replacement, - cache_profile: &snapshot.cache_profile, - }, - )?; - let storage_io = snapshot - .storage_io - .as_ref() - .map(|profile| { - Ok(( - crate::storage_io::estimate_storage_io( - &raw, - scope, - profile, - &snapshot.version, - )?, - crate::storage_io::estimate_storage_io( - &replacement, - scope, - profile, - &snapshot.version, - )?, - )) - }) - .transpose()?; - let handoffs = snapshot - .handoffs - .as_ref() - .map(|profile| { - Ok(( - crate::physical_handoff_cost::estimate_physical_handoffs( - &raw, - scope, - profile, - &snapshot.version, - )?, - crate::physical_handoff_cost::estimate_physical_handoffs( - &replacement, - scope, - profile, - &snapshot.version, - )?, - )) - }) - .transpose()?; - let (mut raw_cost, mut candidate_cost) = match self.calibration.validate() { - Ok(()) => ( - Cost(resources.raw.calibrated_cost(&self.calibration)?), - Cost(resources.candidate.calibrated_cost(&self.calibration)?), - ), - Err(AnalyticalCostError::ZeroCalibration) => { - // Storage estimation above validates the coefficients and - // evidence. At least one priced dimension must remain. - let has_storage_objective = snapshot.storage_io.as_ref().is_some_and(|profile| { - let calibration = &profile.calibration; - [ - calibration.cost_per_disk_read, - calibration.cost_per_disk_write, - calibration.cost_per_object_get, - calibration.cost_per_object_put, - ] - .into_iter() - .any(|coefficient| coefficient > 0.0) - }); - // Boundary estimation above validates evidence and pricing. - // At least one dimension must have a positive coefficient. - let has_handoff_objective = snapshot.handoffs.as_ref().is_some_and(|profile| { - profile.calibration.cost_per_network_byte > 0.0 - || profile.calibration.cost_per_materialization_byte > 0.0 - }); - if !has_storage_objective && !has_handoff_objective { - return Err(AnalyticalCostError::ZeroCalibration); - } - (Cost(0.0), Cost(0.0)) - } - Err(error) => return Err(error), - }; - if let Some((raw, candidate)) = &storage_io { - raw_cost.0 += raw.cost; - candidate_cost.0 += candidate.cost; - } - if let Some((raw, candidate)) = &handoffs { - raw_cost.0 += raw.cost; - candidate_cost.0 += candidate.cost; - } - if !raw_cost.0.is_finite() || !candidate_cost.0.is_finite() { - return Err(AnalyticalCostError::Overflow); - } - Ok(PhysicalPlanComparison { - resources, - raw_cost, - candidate_cost, - storage_io, - handoffs, - }) - } -} - -struct QueryEvidence<'a> { - provider: &'a dyn PlannerPhysicalPlanProvider, - snapshot: &'a PhysicalEvidenceSnapshot, -} - -impl PhysicalNodeEvidenceProvider for QueryEvidence<'_> { - fn evidence( - &self, - request: PhysicalNodeRequest<'_>, - ) -> Result { - self.provider.query_node_evidence(self.snapshot, request) - } -} - -impl CostModel for PhysicalPlanCostModel<'_> { - fn candidate_cost_covers_complete_plan(&self) -> bool { - true - } - - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - target: &TargetSubDAG<'_>, - ) -> Option { - self.estimate_candidate(candidate, target) - .ok() - .filter(|estimate| estimate.candidate_cost < estimate.raw_cost) - .map(|estimate| estimate.candidate_cost) - } - - fn rank_candidates( - &self, - intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - // Physical plans do not exist at algorithm enumeration time. Final - // ranking happens after binding, through `candidate_cost` above. - DefaultCostModel.rank_candidates(intent, candidates) - } - - fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { - self.candidate_cost(candidate, target) - .map_or(f64::NAN, |cost| cost.0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::Cell; - use std::collections::HashMap; - - use asap_types::ir::{NonASAPOp, OperatorNode}; - use asap_types::pre_asap::{DataType, Field, Reduction, Schema, Source}; - use asap_types::types::AccuracyTarget; - use asap_types::workload::{DataArrival, DurationMs, QueryRecurrence, TimestampMs}; - - use crate::analytical_cost::{ExecutionMultiplicity, PhysicalDAGNode, PhysicalOperator}; - use crate::physical_operator_statistics::{ - EdgeStatistics, OperatorStatistics, ScanSelection, UnaryEdgeStatistics, - }; - use crate::replacement::ReplacementStrategy; - - fn edge(rows: u64, bytes: u64) -> EdgeStatistics { - EdgeStatistics { rows, bytes } - } - - fn unary_edges(input: EdgeStatistics, output: EdgeStatistics) -> UnaryEdgeStatistics { - UnaryEdgeStatistics { - input, - output, - promql: None, - } - } - - fn scan_statistics(source_read_bytes: u64, edge: EdgeStatistics) -> OperatorStatistics { - OperatorStatistics::Scan { - edges: unary_edges(edge, edge), - source_read_bytes, - } - } - - fn aggregate_statistics(input: EdgeStatistics, output: EdgeStatistics) -> OperatorStatistics { - OperatorStatistics::HashAggregate { - edges: unary_edges(input, output), - group_count: 1, - key_bytes: 0, - accumulator_bytes_per_group: 8, - } - } - - fn pass_through_statistics(edge: EdgeStatistics) -> OperatorStatistics { - OperatorStatistics::PassThrough { - edges: unary_edges(edge, edge), - } - } - - fn query() -> Rc { - let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::Table { - table_ref: "events".into(), - }, - predicates: vec![], - schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - })) - .unwrap(); - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::max_error(0.01), - }], - output_names: vec![], - filters: vec![], - having: None, - child: scan, - })) - .unwrap() - } - - fn scope() -> ComparisonScope { - ComparisonScope { - data_arrival: DataArrival::AtRest, - planning_time: TimestampMs(1_000), - horizon: DurationMs(10_000), - recurrence: QueryRecurrence::OneTime { - invocations: 10, - execute_at: None, - }, - sources: vec![ScanSelection { - source: Source::Table { - table_ref: "events".into(), - }, - source_snapshot_id: "snapshot-1".into(), - predicates: vec![], - info_matchers: vec![], - }], - } - } - - struct TestProvider { - storage_io: Option, - summary_available: bool, - candidate_scan_bytes: u64, - handoffs: Option, - snapshot_calls: Cell, - raw_evidence_calls: Cell, - } - - impl TestProvider { - fn new(summary_available: bool, candidate_scan_bytes: u64) -> Self { - Self { - storage_io: None, - summary_available, - candidate_scan_bytes, - handoffs: None, - snapshot_calls: Cell::new(0), - raw_evidence_calls: Cell::new(0), - } - } - - fn summary_dag(&self, scope: &ComparisonScope) -> PhysicalDAG { - let scan_statistics = scan_statistics(self.candidate_scan_bytes, edge(100, 800)); - let aggregate_statistics = aggregate_statistics(edge(100, 800), edge(1, 8)); - let read_statistics = pass_through_statistics(edge(1, 8)); - let evidence = HashMap::from([ - ( - "candidate-scan".into(), - PhysicalNodeEvidence { - physical_id: "candidate-scan".into(), - statistics: scan_statistics, - output_buffer_bytes: 8, - }, - ), - ( - "candidate-state".into(), - PhysicalNodeEvidence { - physical_id: "candidate-state".into(), - statistics: aggregate_statistics, - output_buffer_bytes: 8, - }, - ), - ( - "candidate-read".into(), - PhysicalNodeEvidence { - physical_id: "candidate-read".into(), - statistics: read_statistics, - output_buffer_bytes: 8, - }, - ), - ]); - PhysicalDAG { - nodes: vec![ - PhysicalDAGNode { - id: "candidate-scan".into(), - operator: PhysicalOperator::Scan, - children: vec![], - scan_selection: Some(scope.sources[0].clone()), - output_buffer_bytes: 8, - retained_bytes: 0, - execution: ExecutionMultiplicity::Once, - }, - PhysicalDAGNode { - id: "candidate-state".into(), - operator: PhysicalOperator::HashAggregate { - grouping_key_count: 0, - accumulator_count: 1, - }, - children: vec!["candidate-scan".into()], - scan_selection: None, - output_buffer_bytes: 8, - retained_bytes: 8, - execution: ExecutionMultiplicity::Once, - }, - PhysicalDAGNode { - id: "candidate-read".into(), - operator: PhysicalOperator::PassThrough, - children: vec!["candidate-state".into()], - scan_selection: None, - output_buffer_bytes: 8, - retained_bytes: 0, - execution: ExecutionMultiplicity::PerEvaluation, - }, - ], - root: "candidate-read".into(), - evidence, - } - } - } - - impl PlannerPhysicalPlanProvider for TestProvider { - fn capture_evidence_snapshot( - &self, - _target: &TargetSubDAG<'_>, - ) -> Result { - self.snapshot_calls.set(self.snapshot_calls.get() + 1); - Ok(PhysicalEvidenceSnapshot { - version: "test-snapshot-1".into(), - scope: scope(), - cache_profile: CacheProfile::no_cache(), - storage_io: self.storage_io.clone(), - handoffs: self.handoffs.clone(), - }) - } - - fn query_node_evidence( - &self, - snapshot: &PhysicalEvidenceSnapshot, - request: PhysicalNodeRequest<'_>, - ) -> Result { - assert_eq!(snapshot.version, "test-snapshot-1"); - self.raw_evidence_calls - .set(self.raw_evidence_calls.get() + 1); - let (physical_id, statistics) = match request.operator { - PhysicalOperator::Scan => ("raw-scan", scan_statistics(800, edge(100, 800))), - PhysicalOperator::HashAggregate { - grouping_key_count: 0, - accumulator_count: 1, - } => ( - "raw-aggregate", - aggregate_statistics(edge(100, 800), edge(1, 8)), - ), - _ => return Err(AnalyticalCostError::UnsupportedQueryOperator), - }; - Ok(PhysicalNodeEvidence { - physical_id: physical_id.into(), - output_buffer_bytes: 8, - statistics, - }) - } - - fn summary_physical_dag( - &self, - snapshot: &PhysicalEvidenceSnapshot, - _summary: &Rc, - _target: &TargetSubDAG<'_>, - ) -> Result { - assert_eq!(snapshot.version, "test-snapshot-1"); - self.summary_available - .then(|| self.summary_dag(&snapshot.scope)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_physical_plan")) - } - } - - fn calibration() -> ResourceCalibration { - ResourceCalibration { - cost_per_cpu_op: 1.0, - cost_per_scan_byte: 1.0, - cost_per_retained_byte: 1.0, - version: "test-v1".into(), - } - } - - /// Both base-priced and storage-only objectives need identifiable calibration. - #[test] - fn blank_base_calibration_version_is_rejected_before_snapshot_lookup() { - let provider = TestProvider::new(true, 800); - for version in ["", " \t\n"] { - for zero_base in [false, true] { - let mut base = calibration(); - base.version = version.into(); - if zero_base { - base.cost_per_cpu_op = 0.0; - base.cost_per_scan_byte = 0.0; - base.cost_per_retained_byte = 0.0; - } - assert!(matches!( - PhysicalPlanCostModel::new(&provider, base), - Err(AnalyticalCostError::MissingOrStale( - "resource_calibration.version" - )) - )); - } - } - assert_eq!(provider.snapshot_calls.get(), 0); - } - - // A request-only objective ranks complete evidence and rejects absent, - // zero, or invalid supplemental calibration. - #[test] - fn storage_only_objective_requires_positive_valid_storage_calibration() { - use crate::storage_io::*; - let root = query(); - let target = TargetSubDAG::new(&root); - let mut provider = TestProvider::new(true, 800); - let snapshot = provider.capture_evidence_snapshot(&target).unwrap(); - let raw = lower_query_physical_dag( - &root, - &snapshot.scope, - &QueryEvidence { - provider: &provider, - snapshot: &snapshot, - }, - ) - .unwrap(); - let mut profile = StorageIoProfile { - evidence_version: snapshot.version, - observed_at_ms: 900, - valid_until_ms: 2000, - calibration: StorageCalibration { - version: "requests-v1".into(), - cost_per_disk_read: 0.0, - cost_per_disk_write: 0.0, - cost_per_object_get: 1.0, - cost_per_object_put: 0.0, - }, - nodes: HashMap::new(), - }; - for dag in [raw, provider.summary_dag(&snapshot.scope)] { - for node in dag.nodes { - let statistics = dag.evidence[&node.id].statistics.clone(); - let accesses = match statistics { - OperatorStatistics::Scan { - source_read_bytes, .. - } => vec![StorageAccess { - operation: StorageOperation::ObjectGet, - extent_bytes: vec![source_read_bytes], - bytes_per_request: 400, - }], - _ => vec![], - }; - profile.nodes.insert( - node.id.clone(), - StorageNodeEvidence { - node, - statistics, - accesses, - }, - ); - } - } - let base = ResourceCalibration { - cost_per_cpu_op: 0.0, - cost_per_scan_byte: 0.0, - cost_per_retained_byte: 0.0, - version: "unused-base-v1".into(), - }; - let candidates = - crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); - provider.storage_io = Some(profile.clone()); - let model = PhysicalPlanCostModel::new(&provider, base.clone()).unwrap(); - let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); - assert_eq!(estimate.raw_cost.0, 20.0); - assert_eq!(estimate.candidate_cost.0, 2.0); - assert_eq!( - model.candidate_cost(&candidates[0], &target), - Some(Cost(2.0)) - ); - drop(model); - for coefficient in [0.0, -1.0, f64::NAN] { - profile.calibration.cost_per_object_get = coefficient; - provider.storage_io = Some(profile.clone()); - let model = PhysicalPlanCostModel::new(&provider, base.clone()).unwrap(); - assert!(model.estimate_candidate(&candidates[0], &target).is_err()); - } - provider.storage_io = None; - let model = PhysicalPlanCostModel::new(&provider, base).unwrap(); - assert_eq!( - model.estimate_candidate(&candidates[0], &target), - Err(AnalyticalCostError::ZeroCalibration) - ); - } - - /// Shared count defaults must not turn an omitted profile into measured zero I/O. - #[test] - fn missing_storage_profile_remains_unestimated() { - let root = query(); - let target = TargetSubDAG::new(&root); - let candidates = - crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); - let provider = TestProvider::new(true, 800); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); - assert!(estimate.storage_io.is_none()); - } - - // Combined objectives must identify the base calibration even when its - // coefficients are zero and handoffs supply the entire objective. - #[test] - fn blank_base_calibration_version_is_rejected() { - let provider = TestProvider::new(true, 800); - for version in ["", " \t\n"] { - for handoff_only in [false, true] { - let mut calibration = calibration(); - calibration.version = version.into(); - if handoff_only { - calibration.cost_per_cpu_op = 0.0; - calibration.cost_per_scan_byte = 0.0; - calibration.cost_per_retained_byte = 0.0; - } - assert!( - PhysicalPlanCostModel::new(&provider, calibration).is_err(), - "blank base provenance accepted (handoff_only={handoff_only})" - ); - } - } - } - - // Explicit byte pricing can rank complete plans without pricing CPU or scans. - #[test] - fn handoff_only_objective_ranks_complete_plans() { - use crate::physical_handoff_cost::*; - let root = query(); - let target = TargetSubDAG::new(&root); - let mut provider = TestProvider::new(true, 800); - let raw = PhysicalPlanCostModel::new(&provider, calibration()) - .unwrap() - .target_evidence(&target) - .unwrap() - .1; - let candidate = provider.summary_dag(&scope()); - provider.handoffs = Some(PhysicalHandoffProfile { - evidence_version: "test-snapshot-1".into(), - observed_at_ms: 900, - valid_until_ms: 2000, - calibration: PhysicalHandoffCalibration { - version: "network-only-v1".into(), - cost_per_network_byte: 1.0, - cost_per_materialization_byte: 0.0, - }, - plans: [&raw, &candidate] - .into_iter() - .map(|dag| PhysicalHandoffPlanEvidence { - root: dag.root.clone(), - nodes: dag - .nodes - .iter() - .map(|node| { - let statistics = dag.evidence[&node.id].statistics.clone(); - let bytes = statistics.output().bytes; - let handoffs = if matches!(node.operator, PhysicalOperator::Scan) { - vec![PhysicalHandoff { - id: "scan-transfer".into(), - consumer: None, - kind: PhysicalHandoffKind::Network { - source_location: "edge".into(), - destination_location: "backend".into(), - }, - logical_bytes: bytes, - encoded_bytes: bytes, - copies: 1, - }] - } else { - vec![] - }; - ( - node.id.clone(), - PhysicalHandoffNodeEvidence { - node: node.clone(), - statistics, - handoffs, - }, - ) - }) - .collect(), - }) - .collect(), - }); - let zero_base = ResourceCalibration { - cost_per_cpu_op: 0.0, - cost_per_scan_byte: 0.0, - cost_per_retained_byte: 0.0, - version: "handoff-only-v1".into(), - }; - let space = crate::replacement::search_workload_with( - vec![("q", Rc::clone(&root))], - &crate::replacement::default_strategies(), - ) - .unwrap(); - let model = PhysicalPlanCostModel::new(&provider, zero_base.clone()).unwrap(); - let selected = space.global_selection(&model); - assert!(selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_some()); - drop(model); - for coefficient in [0.0, -1.0, f64::NAN] { - provider - .handoffs - .as_mut() - .unwrap() - .calibration - .cost_per_network_byte = coefficient; - let model = PhysicalPlanCostModel::new(&provider, zero_base.clone()).unwrap(); - assert!(space - .global_selection(&model) - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - } - provider.handoffs = None; - let model = PhysicalPlanCostModel::new(&provider, zero_base).unwrap(); - assert!(space - .global_selection(&model) - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - } - - #[test] - fn global_selection_uses_complete_physical_comparison() { - let root = query(); - let space = crate::replacement::search_workload_with( - vec![("q", Rc::clone(&root))], - &crate::replacement::default_strategies(), - ) - .unwrap(); - let planned_root = Rc::clone(&space.roots[0].1); - let provider = TestProvider::new(true, 800); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - - let selected = space.global_selection(&model); - assert!( - selected.for_target(&planned_root).unwrap().chosen.is_some(), - "a fully bound build-once summary cheaper than ten raw scans must be selected" - ); - } - - // Ranking must retain the uncovered byte of a nearly resident buffer cache. - #[test] - fn tiny_buffer_misses_still_affect_global_selection() { - use crate::analytical_cost::{CacheCapacityEvidence, CacheEvidence}; - const WORKING_SET: u64 = 1_u64 << 63; - struct AlmostResident(TestProvider); - impl PlannerPhysicalPlanProvider for AlmostResident { - fn capture_evidence_snapshot( - &self, - target: &TargetSubDAG<'_>, - ) -> Result { - let mut snapshot = self.0.capture_evidence_snapshot(target)?; - snapshot.cache_profile = CacheProfile::Evidence(CacheEvidence { - version: "almost-resident-v1".into(), - distinct_evaluations: 10, - repeated_identical_evaluations: 0, - result_invalidation_ratio: None, - result_cache: CacheCapacityEvidence { - working_set_bytes: 1, - capacity_bytes: 0, - }, - buffer_cache: CacheCapacityEvidence { - working_set_bytes: WORKING_SET, - capacity_bytes: WORKING_SET - 1, - }, - }); - Ok(snapshot) - } - fn query_node_evidence( - &self, - snapshot: &PhysicalEvidenceSnapshot, - request: PhysicalNodeRequest<'_>, - ) -> Result { - let mut evidence = self.0.query_node_evidence(snapshot, request)?; - if let OperatorStatistics::Scan { - source_read_bytes, .. - } = &mut evidence.statistics - { - *source_read_bytes = WORKING_SET; - } - Ok(evidence) - } - fn summary_physical_dag( - &self, - snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, - target: &TargetSubDAG<'_>, - ) -> Result { - self.0.summary_physical_dag(snapshot, summary, target) - } - } - let space = crate::replacement::search_workload_with( - vec![("q", query())], - &crate::replacement::default_strategies(), - ) - .unwrap(); - let provider = AlmostResident(TestProvider::new(true, WORKING_SET)); - let model = PhysicalPlanCostModel::new( - &provider, - ResourceCalibration { - cost_per_cpu_op: 0.0, - cost_per_scan_byte: 1.0, - cost_per_retained_byte: 0.0, - version: "disk-only-v1".into(), - }, - ) - .unwrap(); - let selected = space.global_selection(&model); - assert!( - selected - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_some(), - "one remaining build read must rank ahead of ten remaining raw reads" - ); - } - - // Versioned physical ranking cannot accept an anonymous calibration generation. - #[test] - fn blank_calibration_version_is_rejected_before_ranking() { - let provider = TestProvider::new(true, 800); - for version in ["", " \t\n"] { - let mut coefficients = calibration(); - coefficients.version = version.into(); - assert!(matches!( - PhysicalPlanCostModel::new(&provider, coefficients), - Err(AnalyticalCostError::MissingOrStale( - "resource_calibration.version" - )) - )); - } - } - - #[test] - fn missing_summary_evidence_keeps_the_raw_target() { - let root = query(); - let space = crate::replacement::search_workload_with( - vec![("q", Rc::clone(&root))], - &crate::replacement::default_strategies(), - ) - .unwrap(); - let planned_root = Rc::clone(&space.roots[0].1); - let provider = TestProvider::new(false, 800); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - - let selected = space.global_selection(&model); - assert!( - selected.for_target(&planned_root).unwrap().chosen.is_none(), - "missing physical summary evidence must not fall back to a structural estimate" - ); - } - - #[test] - fn candidate_with_incomplete_scope_is_unavailable() { - struct WrongScope(TestProvider); - impl PlannerPhysicalPlanProvider for WrongScope { - fn capture_evidence_snapshot( - &self, - target: &TargetSubDAG<'_>, - ) -> Result { - self.0.capture_evidence_snapshot(target) - } - - fn query_node_evidence( - &self, - snapshot: &PhysicalEvidenceSnapshot, - request: PhysicalNodeRequest<'_>, - ) -> Result { - self.0.query_node_evidence(snapshot, request) - } - - fn summary_physical_dag( - &self, - snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, - target: &TargetSubDAG<'_>, - ) -> Result { - let mut dag = self.0.summary_physical_dag(snapshot, summary, target)?; - dag.nodes[0] - .scan_selection - .as_mut() - .unwrap() - .source_snapshot_id = "other".into(); - Ok(dag) - } - } - - let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); - let provider = WrongScope(TestProvider::new(true, 800)); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - assert_eq!( - model.candidate_cost(&candidates[0], &TargetSubDAG::new(&root)), - None - ); - } - - #[test] - fn blank_snapshot_version_is_unavailable_before_evidence_lookup() { - struct BlankVersionProvider; - impl PlannerPhysicalPlanProvider for BlankVersionProvider { - fn capture_evidence_snapshot( - &self, - _target: &TargetSubDAG<'_>, - ) -> Result { - Ok(PhysicalEvidenceSnapshot { - version: " \t".into(), - scope: scope(), - cache_profile: CacheProfile::no_cache(), - storage_io: None, - handoffs: None, - }) - } - - fn query_node_evidence( - &self, - _snapshot: &PhysicalEvidenceSnapshot, - _request: PhysicalNodeRequest<'_>, - ) -> Result { - panic!("blank snapshot versions must fail before evidence lookup") - } - - fn summary_physical_dag( - &self, - _snapshot: &PhysicalEvidenceSnapshot, - _summary: &Rc, - _target: &TargetSubDAG<'_>, - ) -> Result { - panic!("blank snapshot versions must fail before summary binding") - } - } - - let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); - let model = PhysicalPlanCostModel::new(&BlankVersionProvider, calibration()).unwrap(); - assert_eq!( - model.candidate_cost(&candidates[0], &TargetSubDAG::new(&root)), - None - ); - } - - #[test] - fn complete_candidate_that_costs_more_than_raw_is_not_selected() { - let root = query(); - let space = crate::replacement::search_workload_with( - vec![("q", Rc::clone(&root))], - &crate::replacement::default_strategies(), - ) - .unwrap(); - let planned_root = Rc::clone(&space.roots[0].1); - let provider = TestProvider::new(true, 100_000); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - - let selected = space.global_selection(&model); - assert!(selected.for_target(&planned_root).unwrap().chosen.is_none()); - } - - #[test] - fn sibling_candidates_share_one_scope_and_raw_baseline() { - let root = query(); - let candidates = crate::replacement::ASAPStrategies::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); - assert!(candidates.len() >= 2); - let provider = TestProvider::new(true, 800); - let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); - let target = TargetSubDAG::new(&root); - - model.estimate_candidate(&candidates[0], &target).unwrap(); - model.estimate_candidate(&candidates[1], &target).unwrap(); - - assert_eq!(provider.snapshot_calls.get(), 1); - assert_eq!( - provider.raw_evidence_calls.get(), - 2, - "the scan and aggregate evidence for the raw baseline are captured once" - ); - } -} diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection.rs b/crates/asap-aware-mapping/src/stage3_plan_selection.rs index 479a7c696..bf0304844 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection.rs @@ -90,6 +90,19 @@ //! A cost error on any candidate aborts selection; it is reachable only from //! bad declared rates or bad [`PlanSelectionSettings`]. //! +//! ## Measured operation costs +//! +//! A deployment that measured its sketches gives the measured CPU time of +//! one update, merge or estimate per sketch configuration +//! ([`PlanSelectionSettings::measured_operation_costs`]). A sketch build +//! (one instance per group), merge or estimate with an entry is priced +//! from it: rows × measured updates (plus the one placement operation per +//! row of a pane chain), states × measured merges, groups × measured +//! estimates. Every other node is priced from the formulas above, and each +//! node reports which ([`NodeCost::price_source`]). Measured times count as +//! CPU operations of [`ResourceSpeed`], so latency and all three cost +//! models read them like any other operation. +//! //! ## Cost models //! //! [`PlanSelectionSettings::cost_model`] (in [`PlanningModels`]) chooses how @@ -152,7 +165,8 @@ use asap_types::ir::{ use asap_types::post_asap::sketch::KLL_MAX_K; use asap_types::post_asap::{ AccuracyGuarantee, AccuracyMetric, CompositionOperator, ExecutionTiming, FieldDataType, - Materialization, Medium, SketchAlgorithm, SketchParams, SketchStatistic, WeightDomain, + GroupingStrategy, Materialization, Medium, SketchAlgorithm, SketchParams, SketchStatistic, + WeightDomain, }; use asap_types::pre_asap::{AggIntent, DataType, Reduction, ScalarValue, Schema, Source}; use asap_types::types::{AccuracyScope, AccuracyTarget}; @@ -182,6 +196,10 @@ use crate::physical_operator_statistics::{ use crate::stage2_physical_asap_optimization::PhysicalCandidate; use crate::PlanningModels; +mod measured_operation_costs; +use measured_operation_costs::measured_cpu_ns; +pub use measured_operation_costs::{MeasuredOperationCost, NodePriceSource, SummaryOperation}; + /// Groups assumed for every `by (...)` reduction, absent group-count evidence. const DEFAULT_GROUP_COUNT: u64 = 100; /// Used only when the data workload does not declare them. @@ -339,6 +357,11 @@ pub struct PlanSelectionSettings { /// Seconds one-off, scheduled and unknown queries are spread over /// (`invocations / horizon` per second); default 3,600. pub non_repeating_query_horizon_seconds: f64, + /// The deployment's measured CPU time per sketch operation; a sketch + /// node with an entry is priced from it, every other node from the + /// formulas ([`measured_operation_costs`]). Default empty: every node + /// is priced from the formulas. + pub measured_operation_costs: Vec, } impl Default for PlanSelectionSettings { @@ -347,13 +370,16 @@ impl Default for PlanSelectionSettings { cost_model: CostModel::UsageOverTime(ResourceUnitCost::illustrative()), resource_speed: ResourceSpeed::illustrative(), non_repeating_query_horizon_seconds: DEFAULT_NON_REPEATING_QUERY_HORIZON_SECONDS, + measured_operation_costs: Vec::new(), } } } impl PlanSelectionSettings { /// Bad input: a negative or non-finite speed, a horizon that is not - /// positive, or a price the chosen cost model reads that is negative or + /// positive, a bad measured table ([`MeasuredOperationCost`]: a + /// negative or non-finite time, a duplicate entry, or `ms_per_cpu_op` + /// 0), or a price the chosen cost model reads that is negative or /// not finite, or every such price 0 (cost model 3 reads none). pub fn validate(&self) -> Result<(), AnalyticalCostError> { for (name, value) in [ @@ -374,6 +400,10 @@ impl PlanSelectionSettings { horizon, )); } + measured_operation_costs::validate( + &self.measured_operation_costs, + self.resource_speed.ms_per_cpu_op, + )?; let read = match &self.cost_model { CostModel::UsageOverTime(p) => vec![ ("cost_per_cpu_op", p.cost_per_cpu_op), @@ -419,6 +449,9 @@ pub struct NodeCost { /// ... bytes": one computation's work, the times it is computed per /// second and what it keeps. pub detail: String, + /// Whether its CPU operations were priced from the measured table or + /// from the formulas. + pub price_source: NodePriceSource, } /// One candidate's usage and price over the whole workload: `usage` is @@ -1943,6 +1976,16 @@ fn price( for e in &dag.edges { producers_of.entry(e.consumer).or_default().push(e.producer); } + // Each sketch node's sketch, to look up in the measured table: a + // build's own (one instance per group; a shared Hydra structure is not + // that sketch), inherited by the merges and estimates over it. + let mut sketch_of: HashMap = HashMap::new(); + // The measured cost of one `operation` on `sketch`, in operations of + // `speed` (measured ns · 10⁻⁶ / ms_per_cpu_op), when the table has it. + let measured_ops = |sketch: Option<&SketchParams>, operation| { + measured_cpu_ns(&settings.measured_operation_costs, sketch?, operation) + .map(|ns| ns * 1e-6 / speed.ms_per_cpu_op) + }; for node in &dag.nodes { let ingestion = node.output_state.timing == ExecutionTiming::IngestionTime; let producers = producers_of.get(&node.id).map_or(&[][..], Vec::as_slice); @@ -1956,6 +1999,28 @@ fn price( }; let series = priced_series(&sources, data); sources_read_by.insert(node.id, sources); + let node_sketch = match &node.payload { + Payload::ASAP( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + grouping: GroupingStrategy::PerSubpopulationInstance, + .. + } + | ASAPOp::PaneChain { + family: FieldDataType::Sketch(kind, _), + grouping: GroupingStrategy::PerSubpopulationInstance, + .. + }, + ) => Some(kind.params()), + Payload::ASAP(ASAPOp::SummaryMerge { .. } | ASAPOp::SummaryEstimate { .. }) => { + producers.first().and_then(|p| sketch_of.get(p).copied()) + } + _ => None, + }; + if let Some(sketch) = node_sketch { + sketch_of.insert(node.id, sketch); + } + let mut price_source = NodePriceSource::Analytical; let input = inputs .first() .copied() @@ -2147,12 +2212,21 @@ fn price( rows: group_count, bytes: group_count * state_bytes, }; - let ops = input.rows as f64 * depth; + let (ops, per_row) = match measured_ops(node_sketch, SummaryOperation::Update) { + Some(per_update) => { + price_source = NodePriceSource::Measured; + ( + input.rows as f64 * per_update, + format!("{per_update:.3} measured ops per update"), + ) + } + None => (input.rows as f64 * depth, format!("depth {depth}")), + }; ( out, Ok(ResourceEstimate::new(ops, out.bytes, 0)), format!( - "build {} into {group_count} states: {} rows x depth {depth}", + "build {} into {group_count} states: {} rows x {per_row}", family_name(family), input.rows ), @@ -2175,13 +2249,25 @@ fn price( rows: states, bytes: states * state_bytes, }; - let ops = input.rows as f64 * (depth + 1.0); + let (ops, per_row) = match measured_ops(node_sketch, SummaryOperation::Update) { + Some(per_update) => { + price_source = NodePriceSource::Measured; + ( + input.rows as f64 * (per_update + 1.0), + format!("(1 + {per_update:.3} measured ops per update)"), + ) + } + None => ( + input.rows as f64 * (depth + 1.0), + format!("(1 + depth {depth})"), + ), + }; ( out, Ok(ResourceEstimate::new(ops, out.bytes, 0)), format!( "build {} in {pane_count} panes of {pane_width_ms} ms for \ - {group_count} groups: {} rows x (1 + depth {depth})", + {group_count} groups: {} rows x {per_row}", family_name(family), input.rows ), @@ -2207,10 +2293,20 @@ fn price( rows: group_count, bytes: input.bytes.div_ceil(input.rows.max(1)) * group_count, }; + let (ops, measured) = match measured_ops(node_sketch, SummaryOperation::Merge) { + Some(per_merge) => { + price_source = NodePriceSource::Measured; + ( + states as f64 * per_merge, + format!(" x {per_merge:.3} measured ops per merge"), + ) + } + None => (states as f64, String::new()), + }; ( out, - Ok(ResourceEstimate::new(states as f64, out.bytes, 0)), - format!("merge {states} states into {group_count}"), + Ok(ResourceEstimate::new(ops, out.bytes, 0)), + format!("merge {states} states into {group_count}{measured}"), ) } Payload::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { @@ -2219,10 +2315,22 @@ fn price( _ => 1, }; let out = edge(input.rows * per_group); + // A measured estimate reads one group's result, all of a + // top-k's `k` entries included. + let (ops, per_state) = match measured_ops(node_sketch, SummaryOperation::Estimate) { + Some(per_estimate) => { + price_source = NodePriceSource::Measured; + ( + input.rows as f64 * per_estimate, + format!("{per_estimate:.3} measured ops per estimate"), + ) + } + None => (out.rows as f64, per_group.to_string()), + }; ( out, - Ok(ResourceEstimate::new(out.rows as f64, 0, 0)), - format!("estimate {} groups x {per_group}", input.rows), + Ok(ResourceEstimate::new(ops, 0, 0)), + format!("estimate {} groups x {per_state}", input.rows), ) } Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) => ( @@ -2347,6 +2455,7 @@ fn price( cost_per_second: cost, multiplier, detail, + price_source, }, ); } @@ -6004,4 +6113,148 @@ mod tests { assert_eq!(choose(Ranking::Memory, &costs).unwrap().0, 1); assert_eq!(choose(Ranking::Latency, &costs).unwrap().0, 1); } + + /// The KLL build of a `quantile_over_time` candidate, with its sketch + /// parameters, and the candidate. + fn kll_build() -> (PhysicalCandidate, PhysicalASAPNodeId, SketchParams) { + let (candidates, _) = per_series_kll(AccuracyScope::PerValue); + let candidate = candidates.into_iter().next().unwrap(); + let (build, sketch) = candidate + .dag + .nodes + .iter() + .find_map(|n| match &n.payload { + Payload::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => Some((n.id, kind.params().clone())), + _ => None, + }) + .unwrap(); + (candidate, build, sketch) + } + + /// A measured update time for the candidate's KLL changes that build's + /// price and nothing else: its CPU is rows × measured ns, counted in + /// operations of `ms_per_cpu_op`, and it reports `Measured`; every + /// other node keeps its analytical price and reports `Analytical`. + #[test] + fn a_measured_entry_prices_exactly_its_node_and_reports_it() { + let (candidate, build, sketch) = kll_build(); + let sketch_of_build = sketch.clone(); + let analytical = PlanSelectionSettings::default(); + let before = price(&candidate.dag, &recurrences(1), &data(), &analytical).unwrap(); + let cpu_ns_per_operation = 250.0; + let measured = PlanSelectionSettings { + measured_operation_costs: vec![MeasuredOperationCost { + sketch, + operation: SummaryOperation::Update, + cpu_ns_per_operation, + }], + ..PlanSelectionSettings::default() + }; + let after = price(&candidate.dag, &recurrences(1), &data(), &measured).unwrap(); + for (id, node) in &after.per_node { + if *id == build { + assert_eq!(node.price_source, NodePriceSource::Measured); + assert_ne!(node.cost_per_second, before.per_node[id].cost_per_second); + } else { + assert_eq!(node.price_source, NodePriceSource::Analytical, "node {id}"); + assert_eq!(node, &before.per_node[id], "node {id}"); + } + } + assert!(after.per_node[&build] + .detail + .contains("250.000 measured ops per update")); + assert!(after.cost_per_second.unwrap() > before.cost_per_second.unwrap()); + // The build's CPU is rows × measured ns, counted in operations of + // `ms_per_cpu_op`: the candidate's CPU operations grow linearly in + // the measured time, (cpu(250) - cpu(100)) / (cpu(400) - cpu(100)) + // = 150 / 300. + let cpu_at = |ns: f64| { + let settings = PlanSelectionSettings { + measured_operation_costs: vec![MeasuredOperationCost { + sketch: sketch_of_build.clone(), + operation: SummaryOperation::Update, + cpu_ns_per_operation: ns, + }], + ..PlanSelectionSettings::default() + }; + price(&candidate.dag, &recurrences(1), &data(), &settings) + .unwrap() + .usage + .cpu_ops_per_second + }; + assert!(close( + (cpu_at(cpu_ns_per_operation) - cpu_at(100.0)) / (cpu_at(400.0) - cpu_at(100.0)), + 0.5 + )); + } + + /// With no measured entry for its sketch, or an entry for another + /// operation, every node is priced from the formulas, as without a table. + #[test] + fn entries_for_other_sketches_or_operations_change_nothing() { + let (candidate, _, sketch) = kll_build(); + let before = price( + &candidate.dag, + &recurrences(1), + &data(), + &PlanSelectionSettings::default(), + ) + .unwrap(); + let other = PlanSelectionSettings { + measured_operation_costs: vec![ + MeasuredOperationCost { + sketch: SketchParams::Cms { + width: 1024, + depth: 3, + }, + operation: SummaryOperation::Update, + cpu_ns_per_operation: 50.0, + }, + MeasuredOperationCost { + sketch, + operation: SummaryOperation::Merge, + cpu_ns_per_operation: 50.0, + }, + ], + ..PlanSelectionSettings::default() + }; + let after = price(&candidate.dag, &recurrences(1), &data(), &other).unwrap(); + assert_eq!(after, before); + } + + /// Bad input: a negative measured time, two entries for one sketch and + /// operation, or a table with `ms_per_cpu_op` 0. + #[test] + fn invalid_measured_tables_are_rejected() { + let entry = |cpu_ns_per_operation| MeasuredOperationCost { + sketch: SketchParams::Cms { + width: 1024, + depth: 3, + }, + operation: SummaryOperation::Update, + cpu_ns_per_operation, + }; + for (table, ms_per_cpu_op) in [ + (vec![entry(-1.0)], 1e-6), + (vec![entry(f64::NAN)], 1e-6), + (vec![entry(1.0), entry(2.0)], 1e-6), + (vec![entry(1.0)], 0.0), + ] { + let mut settings = PlanSelectionSettings { + measured_operation_costs: table, + ..PlanSelectionSettings::default() + }; + settings.resource_speed.ms_per_cpu_op = ms_per_cpu_op; + assert!(settings.validate().is_err()); + } + let mut empty = PlanSelectionSettings::default(); + empty.resource_speed.ms_per_cpu_op = 0.0; + assert!( + empty.validate().is_ok(), + "no table, no positive speed needed" + ); + } } diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs b/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs new file mode 100644 index 000000000..456ebe247 --- /dev/null +++ b/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs @@ -0,0 +1,110 @@ +//! Measured CPU time of summary operations, which plan selection prices +//! from instead of its formulas. +//! +//! A deployment measures what one update, merge or estimate of a sketch +//! with given parameters costs (e.g. sketch-bench's `rqe_atomic_costs.json`, +//! converted by `asap-devtools`' `convert_rqe_atomic_costs`) and puts the +//! table in [`PlanSelectionSettings::measured_operation_costs`]. Stage 3 +//! prices a sketch node's CPU from the entry for its sketch parameters and +//! operation when there is one, and from its formulas otherwise, and +//! reports which per node ([`NodeCost::price_source`]). The planner reads +//! only this table, never a benchmark file. +//! +//! A measured time enters [`ResourceUsage`] as CPU operations: `t` ns is +//! `t · 10⁻⁶ / ms_per_cpu_op` operations of [`ResourceSpeed`], so latency +//! (operations × `ms_per_cpu_op`) is the measured time, and every cost model +//! prices it as it prices any operation. +//! +//! [`PlanSelectionSettings::measured_operation_costs`]: super::PlanSelectionSettings::measured_operation_costs +//! [`NodeCost::price_source`]: super::NodeCost::price_source +//! [`ResourceUsage`]: super::ResourceUsage +//! [`ResourceSpeed`]: super::ResourceSpeed + +use asap_types::post_asap::SketchParams; +use serde::{Deserialize, Serialize}; + +use crate::analytical_cost::AnalyticalCostError; + +/// One operation on a sketch's state, as plan selection prices it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SummaryOperation { + /// Insert one input row (a `SummaryAgg` or `PaneChain` build). + Update, + /// Merge one input state into another (a `SummaryMerge`). + Merge, + /// Read one result out of one state (a `SummaryEstimate`, per group). + Estimate, +} + +/// The measured CPU time of one operation on one sketch configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MeasuredOperationCost { + /// The sketch and its parameters, e.g. `{"Kll": {"k": 200}}`; matched + /// with `SketchParams`' equality, which ignores the error and failure + /// probability a sketch was sized for. + pub sketch: SketchParams, + /// Which operation was measured. + pub operation: SummaryOperation, + /// CPU nanoseconds one such operation takes on one core. + pub cpu_ns_per_operation: f64, +} + +/// Which prices one node's CPU operations came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NodePriceSource { + /// Plan selection's formulas (`analytical_cost` and the sketch shapes). + #[default] + Analytical, + /// The entry of [`MeasuredOperationCost`] for its sketch and operation. + Measured, +} + +/// The measured CPU nanoseconds of `operation` on `sketch`, if `table` has +/// an entry for them. +pub(super) fn measured_cpu_ns( + table: &[MeasuredOperationCost], + sketch: &SketchParams, + operation: SummaryOperation, +) -> Option { + table + .iter() + .find(|entry| entry.operation == operation && &entry.sketch == sketch) + .map(|entry| entry.cpu_ns_per_operation) +} + +/// Bad input: an entry whose time is negative or not finite, two entries +/// for one sketch and operation, or a table with `ms_per_cpu_op` 0 (a +/// measured time cannot then be counted in operations). +pub(super) fn validate( + table: &[MeasuredOperationCost], + ms_per_cpu_op: f64, +) -> Result<(), AnalyticalCostError> { + if !table.is_empty() && ms_per_cpu_op <= 0.0 { + return Err(AnalyticalCostError::InvalidCalibration( + "ms_per_cpu_op (measured_operation_costs needs it positive)", + ms_per_cpu_op, + )); + } + for (index, entry) in table.iter().enumerate() { + let ns = entry.cpu_ns_per_operation; + if !ns.is_finite() || ns < 0.0 { + return Err(AnalyticalCostError::InvalidCalibration( + "measured_operation_costs.cpu_ns_per_operation", + ns, + )); + } + if table[..index] + .iter() + .any(|earlier| earlier.operation == entry.operation && earlier.sketch == entry.sketch) + { + return Err(AnalyticalCostError::InvalidCalibration( + "measured_operation_costs: two entries for one sketch and operation", + ns, + )); + } + } + Ok(()) +} diff --git a/crates/asap-aware-mapping/tests/data/offline-evidence-synthetic.json b/crates/asap-aware-mapping/tests/data/offline-evidence-synthetic.json deleted file mode 100644 index 77d061944..000000000 --- a/crates/asap-aware-mapping/tests/data/offline-evidence-synthetic.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "schema_version": 1, - "benchmark_version": "synthetic-fixture-v1", - "model_version": "empirical-update-cpu-v1", - "records": [ - { - "id": "synthetic-test-cms", - "algorithm": "Cms", - "params": { - "Cms": { - "width": 272, - "depth": 5 - } - }, - "distribution": { - "id": "synthetic-test-uniform", - "family": "uniform", - "sample_count": 1000, - "distinct_count": 100, - "parameters": { - "seed": 42 - } - }, - "environment": { - "id": "synthetic-test-machine", - "cpu": "synthetic CPU", - "os": "synthetic OS", - "runtime": "synthetic runtime", - "implementation": "SYNTHETIC TEST FIXTURE, NOT MEASURED", - "implementation_version": "test-v1" - }, - "measured_at_unix_seconds": 100, - "valid_until_unix_seconds": 200, - "provenance": { - "command": "synthetic fixture; no benchmark was run", - "dataset": "synthetic fixture", - "source_revision": "test-fixture-v1", - "repetitions": 3 - }, - "metrics": { - "build_cpu_ns": null, - "update_cpu_ns": { - "value": 20, - "stddev": 1, - "samples": 3, - "method": "fabricated values for schema/integration tests only" - }, - "merge_cpu_ns": null, - "read_cpu_ns": null, - "retained_bytes": null, - "peak_bytes": null, - "serialized_bytes": null, - "disk_bytes": null - }, - "error": { - "metric": "absolute relative frequency error (dimensionless)", - "mean": 0.001, - "max": null, - "trials": 3, - "ground_truth_method": "synthetic fixture only", - "query": { - "kind": "point_frequency" - } - } - } - ] -} From 281211f8038228f3569d8a3f35d4046c11bbfb0a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:36:11 +0000 Subject: [PATCH 2/3] feat(devtools): convert sketch-bench rqe_atomic_costs.json; stage_pipeline --measured-operation-costs convert_rqe_atomic_costs turns sketch-bench's per-operation table into plan selection's measured table (exact and Hydra rows skipped with the reason). stage_pipeline --measured-operation-costs passes it to Stage 3, and each node in the document carries price_source. The committed example 1 fixture gains price_source: "analytical" per node. --- .../src/bin/convert_rqe_atomic_costs.rs | 34 + crates/devtools/src/bin/stage_pipeline.rs | 23 +- crates/devtools/src/lib.rs | 5 +- crates/devtools/src/rqe_atomic_costs.rs | 274 +++++++ .../examples/planner-layering-example1.json | 720 ++++++++++++------ 5 files changed, 813 insertions(+), 243 deletions(-) create mode 100644 crates/devtools/src/bin/convert_rqe_atomic_costs.rs create mode 100644 crates/devtools/src/rqe_atomic_costs.rs diff --git a/crates/devtools/src/bin/convert_rqe_atomic_costs.rs b/crates/devtools/src/bin/convert_rqe_atomic_costs.rs new file mode 100644 index 000000000..1c1857b11 --- /dev/null +++ b/crates/devtools/src/bin/convert_rqe_atomic_costs.rs @@ -0,0 +1,34 @@ +// cargo run -p asap-devtools --bin convert_rqe_atomic_costs -- \ +// optimizer_cost/rqe_atomic_costs.json measured_operation_costs.json +// +// Reads sketch-bench's per-operation cost table (`rqe_atomic_costs.json`) +// and writes plan selection's measured table: a JSON array of +// `{"sketch": , "operation": "update|merge|estimate", +// "cpu_ns_per_operation": }`, three entries per converted row +// (`asap_devtools::rqe_atomic_costs`). Pass it to `stage_pipeline +// --measured-operation-costs`, or set it as +// `PlanSelectionSettings::measured_operation_costs`. Skipped rows (exact +// accumulators, Hydra structures, unknown names) are listed on stderr. + +use asap_devtools::rqe_atomic_costs::convert_rqe_atomic_costs; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let (Some(input), Some(output), None) = (args.next(), args.next(), args.next()) else { + return Err("usage: convert_rqe_atomic_costs ".into()); + }; + let rows: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&input)?)?; + let converted = convert_rqe_atomic_costs(&rows)?; + for skipped in &converted.skipped_rows { + eprintln!("skipped {}: {}", skipped.sketch_name, skipped.reason); + } + std::fs::write( + &output, + serde_json::to_string_pretty(&converted.measured_operation_costs)?, + )?; + eprintln!( + "wrote {} entries to {output}", + converted.measured_operation_costs.len() + ); + Ok(()) +} diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index a787ab4aa..64482a285 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -86,6 +86,11 @@ // also offered built at query time and kept across evaluations, beside being // shared within one evaluation. The document's top-level // `deployment_capabilities` shows the inputs used. +// `--measured-operation-costs ` reads the deployment's measured CPU +// time per sketch operation (a JSON array, as `convert_rqe_atomic_costs` +// writes from sketch-bench's `rqe_atomic_costs.json`); a sketch node with an +// entry is priced from it, and each node's `price_source` says `measured` +// or `analytical`. // `--sample-interval-ms` is the sample interval declared for every metric the // PromQL queries read (default 15000); the data declares no other facts. // `--table ''` (repeatable) declares a table the SQL queries read: `{"name": ..., @@ -100,8 +105,8 @@ use asap_aware_mapping::per_computation_candidates::{ ComputationCandidate, PerComputationCandidates, PruneReason, }; use asap_aware_mapping::stage3_plan_selection::{ - ClusterLimits, ClusterObjective, CostModel, PlanSelectionSettings, Rejection, ResourceUnitCost, - ResourceUsage, Selection, SelectionError, + ClusterLimits, ClusterObjective, CostModel, MeasuredOperationCost, PlanSelectionSettings, + Rejection, ResourceUnitCost, ResourceUsage, Selection, SelectionError, }; use asap_aware_mapping::DeploymentCapabilities; use asap_devtools::candidate_labels::{candidate_labels, computation_label, query_of_each_target}; @@ -218,6 +223,7 @@ fn run(args: Vec) -> Result<(), Failure> { let mut cluster_objective: Option = None; let mut deployment_capabilities = DeploymentCapabilities::default(); let mut out = None; + let mut measured_operation_costs_path: Option = None; let mut args = args.into_iter(); while let Some(flag) = args.next() { let mut value = || args.next().ok_or(format!("{flag} needs a value")); @@ -270,6 +276,7 @@ fn run(args: Vec) -> Result<(), Failure> { .map_err(|e| format!("--deployment-capabilities: {e}"))? } "--out" => out = Some(value()?), + "--measured-operation-costs" => measured_operation_costs_path = Some(value()?), other => return Err(format!("unknown argument {other}").into()), } } @@ -313,8 +320,19 @@ fn run(args: Vec) -> Result<(), Failure> { } (Some(other), _) => return Err(format!("unknown cost model {other}").into()), }; + let measured_operation_costs: Vec = match &measured_operation_costs_path + { + Some(path) => { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("--measured-operation-costs {path}: {e}"))?; + serde_json::from_str(&text) + .map_err(|e| format!("--measured-operation-costs {path}: {e}"))? + } + None => Vec::new(), + }; let plan_selection = PlanSelectionSettings { cost_model, + measured_operation_costs, ..PlanSelectionSettings::default() }; let mut document = match (example.as_deref(), queries.is_empty()) { @@ -564,6 +582,7 @@ fn plan_selection_json( "cost_per_second": c.cost_per_second, "multiplier": c.multiplier, "detail": c.detail, + "price_source": c.price_source, }), ) }) diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index 60623a7af..4dcd229a4 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -15,11 +15,14 @@ //! [`planning_stages`] and [`planner_layering_examples`] hold the one //! pipeline and the #509 example workloads that `stage_pipeline` and the //! planner-layering acceptance tests share; [`candidate_labels`] names the -//! candidates for the viewer document. +//! candidates for the viewer document. [`rqe_atomic_costs`] converts +//! sketch-bench's measured per-operation costs into plan selection's +//! measured table. pub mod candidate_labels; pub mod planner_layering_examples; pub mod planning_stages; +pub mod rqe_atomic_costs; pub use asap_frontend_promql::{ lower_promql_workload, lower_promql_workload_with_histograms, PromqlError, diff --git a/crates/devtools/src/rqe_atomic_costs.rs b/crates/devtools/src/rqe_atomic_costs.rs new file mode 100644 index 000000000..ce809597e --- /dev/null +++ b/crates/devtools/src/rqe_atomic_costs.rs @@ -0,0 +1,274 @@ +//! Converts sketch-bench's per-operation cost table (`rqe_atomic_costs.json`) +//! into plan selection's measured table +//! ([`PlanSelectionSettings::measured_operation_costs`]). +//! +//! sketch-bench writes one row per sketch configuration: +//! +//! ```json +//! {"sketch": "kll-percall", "sketch_config": {"algorithm": "kll-percall", "params": {"k": 200}}, +//! "insert_cpu_secs": 8.5e-8, "merge_cpu_secs": 1.2e-6, "query_cpu_secs": 4.0e-7, ...} +//! ``` +//! +//! and each becomes three entries, one per operation, in CPU nanoseconds: +//! +//! ```json +//! [{"sketch": {"Kll": {"k": 200}}, "operation": "update", "cpu_ns_per_operation": 85.0}, +//! {"sketch": {"Kll": {"k": 200}}, "operation": "merge", "cpu_ns_per_operation": 1200.0}, +//! {"sketch": {"Kll": {"k": 200}}, "operation": "estimate", "cpu_ns_per_operation": 400.0}] +//! ``` +//! +//! Only this converter reads the sketch-bench format; the planner reads the +//! table. Rows plan selection cannot price from the table are skipped with +//! the reason: exact accumulators, Hydra structures (shared across groups, +//! measured at one label width) and sketch names with no planner sketch. +//! +//! [`PlanSelectionSettings::measured_operation_costs`]: asap_aware_mapping::stage3_plan_selection::PlanSelectionSettings::measured_operation_costs + +use asap_aware_mapping::stage3_plan_selection::{MeasuredOperationCost, SummaryOperation}; +use asap_types::post_asap::SketchParams; +use serde_json::Value; + +/// A sketch-bench row that gave no entries, and why. +#[derive(Debug, Clone, PartialEq)] +pub struct SkippedRow { + /// The row's `sketch` name, e.g. `exact-sum`. + pub sketch_name: String, + /// Why plan selection cannot price from it. + pub reason: String, +} + +/// What [`convert_rqe_atomic_costs`] produced. +#[derive(Debug, Clone, PartialEq)] +pub struct ConvertedCosts { + /// The measured table: three entries per converted row (update, merge, + /// estimate), in row order. + pub measured_operation_costs: Vec, + /// The rows skipped, in row order. + pub skipped_rows: Vec, +} + +/// Converts the parsed `rqe_atomic_costs.json` (a JSON array of rows). +/// Errors (bad input): not an array, a row missing its name, parameters or +/// a CPU time, a time that is not a non-negative number, a parameter of the +/// wrong type, or two rows for one sketch configuration. +pub fn convert_rqe_atomic_costs(rows: &Value) -> Result { + let rows = rows + .as_array() + .ok_or("rqe_atomic_costs.json is not a JSON array of rows")?; + let mut converted = ConvertedCosts { + measured_operation_costs: Vec::new(), + skipped_rows: Vec::new(), + }; + for (index, row) in rows.iter().enumerate() { + let sketch_name = row["sketch"] + .as_str() + .ok_or(format!("row {index}: no `sketch` name"))?; + let params = &row["sketch_config"]["params"]; + let sketch = match sketch_params(sketch_name, params) { + Ok(Some(sketch)) => sketch, + Ok(None) => { + converted.skipped_rows.push(SkippedRow { + sketch_name: sketch_name.to_string(), + reason: skip_reason(sketch_name).to_string(), + }); + continue; + } + Err(error) => return Err(format!("row {index} ({sketch_name}): {error}")), + }; + if converted + .measured_operation_costs + .iter() + .any(|entry| entry.sketch == sketch) + { + return Err(format!( + "row {index} ({sketch_name}): a second row for {sketch:?}" + )); + } + for (field, operation) in [ + ("insert_cpu_secs", SummaryOperation::Update), + ("merge_cpu_secs", SummaryOperation::Merge), + ("query_cpu_secs", SummaryOperation::Estimate), + ] { + let seconds = row[field] + .as_f64() + .filter(|s| s.is_finite() && *s >= 0.0) + .ok_or(format!( + "row {index} ({sketch_name}): `{field}` is not a non-negative number" + ))?; + converted + .measured_operation_costs + .push(MeasuredOperationCost { + sketch: sketch.clone(), + operation, + cpu_ns_per_operation: seconds * 1e9, + }); + } + } + Ok(converted) +} + +/// Why a row whose name has no planner sketch is skipped. +fn skip_reason(sketch_name: &str) -> &'static str { + if sketch_name.starts_with("exact-") { + "an exact accumulator: the table prices sketches only" + } else if sketch_name.starts_with("hydra-") { + "a Hydra structure shared across groups: the table prices one sketch per group" + } else { + "no planner sketch has this name" + } +} + +/// The planner sketch a sketch-bench name and its parameters configure, +/// `None` for a name with none. Names: `kll*` (`k`), `dd*` (`alpha`), `hll` +/// (`lg_k`), `kmv` (`k`), `cms` and `count-sketch` (`rows`, `cols`), and +/// their top-k variants (a name containing `heap`, with `heap`). +fn sketch_params(sketch_name: &str, params: &Value) -> Result, String> { + let integer = |name: &str| -> Result { + params[name] + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .ok_or(format!( + "parameter `{name}` is not a 32-bit unsigned integer" + )) + }; + let with_heap = sketch_name.contains("heap"); + let sketch = if sketch_name.starts_with("hydra-") || sketch_name.starts_with("exact-") { + return Ok(None); + } else if sketch_name.starts_with("kll") { + SketchParams::Kll { + k: integer("k")?, + failure_probability: None, + } + } else if sketch_name.starts_with("dd") { + SketchParams::DDSketch { + alpha: params["alpha"] + .as_f64() + .ok_or("parameter `alpha` is not a number")?, + } + } else if sketch_name == "hll" { + SketchParams::Hll { + precision: u8::try_from(integer("lg_k")?).map_err(|e| e.to_string())?, + relative_error: None, + } + } else if sketch_name == "kmv" { + SketchParams::Kmv { + k: integer("k")?, + relative_error: None, + } + } else if sketch_name.starts_with("cms") && with_heap { + SketchParams::CmsWithHeap { + width: integer("cols")?, + depth: integer("rows")?, + heap_size: integer("heap")?, + } + } else if sketch_name == "cms" { + SketchParams::Cms { + width: integer("cols")?, + depth: integer("rows")?, + } + } else if sketch_name.starts_with("count-sketch") && with_heap { + SketchParams::CountSketchWithHeap { + width: integer("cols")?, + depth: integer("rows")?, + heap_size: integer("heap")?, + } + } else if sketch_name == "count-sketch" { + SketchParams::CountSketch { + width: integer("cols")?, + depth: integer("rows")?, + } + } else { + return Ok(None); + }; + Ok(Some(sketch)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Four sketch-bench rows in the format of `rqe_atomic_costs.json`: a + /// KLL and a Count-Min top-k convert, the exact sum and the Hydra HLL + /// are skipped. The table written as JSON reads back as the same + /// entries, with seconds turned into nanoseconds. + #[test] + fn a_small_sample_converts_and_round_trips() { + let sample = json!([ + {"sketch": "kll-percall", "sketch_config": {"algorithm": "kll-percall", "params": {"k": 200}}, + "mem_bytes_per_instance": 9648.0, "insert_cpu_secs": 8.5e-8, "merge_cpu_secs": 1.2e-6, + "query_cpu_secs": 4.0e-7, "query_accuracy": {}, "accuracy_metric": "mean_rank_err", + "measured_at": {"items_per_instance": 1000000}}, + {"sketch": "cms-heap-topk-fastpath-vector2d", + "sketch_config": {"algorithm": "cms-heap-topk-fastpath-vector2d", + "params": {"cols": 1024, "heap": 32, "rows": 3}}, + "insert_cpu_secs": 6.0e-8, "merge_cpu_secs": 5.0e-6, "query_cpu_secs": 3.5e-7}, + {"sketch": "exact-sum", "sketch_config": {"algorithm": "exact-sum", "params": {}}, + "insert_cpu_secs": 1.0e-9, "merge_cpu_secs": 1.0e-9, "query_cpu_secs": 1.0e-9}, + {"sketch": "hydra-hll", "sketch_config": {"algorithm": "hydra-hll", "params": {"cols": 1024, "rows": 3}}, + "insert_cpu_secs": 1.0e-6, "merge_cpu_secs": 1.0e-6, "query_cpu_secs": 1.0e-6} + ]); + let converted = convert_rqe_atomic_costs(&sample).unwrap(); + let kll = SketchParams::Kll { + k: 200, + failure_probability: None, + }; + let cms = SketchParams::CmsWithHeap { + width: 1024, + depth: 3, + heap_size: 32, + }; + let expected: Vec<_> = [ + (&kll, SummaryOperation::Update, 85.0), + (&kll, SummaryOperation::Merge, 1200.0), + (&kll, SummaryOperation::Estimate, 400.0), + (&cms, SummaryOperation::Update, 60.0), + (&cms, SummaryOperation::Merge, 5000.0), + (&cms, SummaryOperation::Estimate, 350.0), + ] + .into_iter() + .map(|(sketch, operation, ns)| (sketch.clone(), operation, ns)) + .collect(); + let got: Vec<_> = converted + .measured_operation_costs + .iter() + .map(|e| (e.sketch.clone(), e.operation, e.cpu_ns_per_operation)) + .collect(); + assert_eq!(got.len(), expected.len()); + for ((sketch, operation, ns), (want_sketch, want_operation, want_ns)) in + got.iter().zip(&expected) + { + assert_eq!((sketch, operation), (want_sketch, want_operation)); + assert!((ns - want_ns).abs() < 1e-9 * want_ns, "{ns} vs {want_ns}"); + } + let skipped: Vec<_> = converted + .skipped_rows + .iter() + .map(|s| s.sketch_name.as_str()) + .collect(); + assert_eq!(skipped, ["exact-sum", "hydra-hll"]); + + let written = serde_json::to_string(&converted.measured_operation_costs).unwrap(); + let read: Vec = serde_json::from_str(&written).unwrap(); + assert_eq!(read, converted.measured_operation_costs); + } + + /// Bad input is an error, not a skipped row: a missing time, a negative + /// time, a parameter of the wrong type, or a configuration twice. + #[test] + fn malformed_rows_are_errors() { + let kll = |k: Value, insert: Value| { + json!({"sketch": "kll", "sketch_config": {"params": {"k": k}}, + "insert_cpu_secs": insert, "merge_cpu_secs": 1e-6, "query_cpu_secs": 1e-6}) + }; + for rows in [ + json!([kll(json!(200), Value::Null)]), + json!([kll(json!(200), json!(-1.0))]), + json!([kll(json!("200"), json!(1e-7))]), + json!([kll(json!(200), json!(1e-7)), kll(json!(200), json!(2e-7))]), + json!({"rows": []}), + ] { + assert!(convert_rqe_atomic_costs(&rows).is_err(), "{rows}"); + } + } +} diff --git a/tools/dag-viewer/examples/planner-layering-example1.json b/tools/dag-viewer/examples/planner-layering-example1.json index 80082ab92..fc3f288ee 100644 --- a/tools/dag-viewer/examples/planner-layering-example1.json +++ b/tools/dag-viewer/examples/planner-layering-example1.json @@ -103825,67 +103825,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -103915,62 +103928,74 @@ "0": { "cost_per_second": 4.64, "detail": "scan 4000000 samples; x 0.2000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104000,57 +104025,68 @@ "0": { "cost_per_second": 4.64, "detail": "scan 4000000 samples; x 0.2000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.2000/s", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104080,67 +104116,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104170,67 +104219,80 @@ "0": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104260,62 +104322,74 @@ "0": { "cost_per_second": 4.64, "detail": "scan 4000000 samples; x 0.2000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104345,67 +104419,80 @@ "0": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.4, "detail": "build exact Sum accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104435,67 +104522,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104525,67 +104625,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104615,62 +104728,74 @@ "0": { "cost_per_second": 4.64, "detail": "scan 4000000 samples; x 0.2000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104700,67 +104825,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.4, "detail": "time range 60s: keep 4000000 of 4000000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.4, "detail": "build exact Rate accumulator into 1000000 states: 4000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104790,67 +104928,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104880,67 +105031,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -104970,67 +105134,80 @@ "0": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -105060,67 +105237,80 @@ "0": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -105150,62 +105340,74 @@ "0": { "cost_per_second": 4.64, "detail": "scan 4000000 samples; x 0.2000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -105235,67 +105437,80 @@ "0": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 0.8, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -105325,67 +105540,80 @@ "0": { "cost_per_second": 0.3866686, "detail": "scan 66667 samples; x 1.0000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "12": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 2.32, "detail": "scan 4000000 samples; x 0.1000/s; keeps 192000000 bytes of raw data on disk", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.8, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 4000000 rows x (1 + depth 1); x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", @@ -105415,62 +105643,74 @@ "0": { "cost_per_second": 0.7733372, "detail": "scan 66667 samples; x 2.0000/s", - "multiplier": 2 + "multiplier": 2, + "price_source": "analytical" }, "1": { "cost_per_second": 7.133334, "detail": "build exact Rate accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "10": { "cost_per_second": 0.0001, "detail": "limit to 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "11": { "cost_per_second": 0.0001, "detail": "operator: pass 1000 rows; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "2": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "3": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "4": { "cost_per_second": 0.1, "detail": "build exact Sum accumulator into 100 states: 1000000 rows x depth 1; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "5": { "cost_per_second": 9.999999999999999e-6, "detail": "finalize 100 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "6": { "cost_per_second": 7.133334, "detail": "build exact Sum accumulator in 6 panes of 10000 ms for 1000000 groups: 66667 rows x (1 + depth 1); x 1.0000/s; keeps 56000000 bytes", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "7": { "cost_per_second": 0.6000000000000001, "detail": "merge 6000000 states into 1000000; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "8": { "cost_per_second": 0.1, "detail": "finalize 1000000 accumulators; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" }, "9": { "cost_per_second": 1.4000000000000001, "detail": "sort 1000000 rows in 100 partitions; x 0.1000/s", - "multiplier": 1 + "multiplier": 1, + "price_source": "analytical" } }, "source": "analytical-cost-v2 (illustrative statistics)", From 2745b4710b3e388f8ec21c216eb2d408509139c9 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:08:25 +0000 Subject: [PATCH 3/3] fix(asap-aware-mapping): measured merges in compaction latency; own errors and cost source for the measured table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The newest window's compaction prices each of its (k - 1)·I merges from the state's measured merge when the table has one. - DuplicateMeasuredOperationCost (names the sketch and operation) and MeasuredCostsWithoutCpuSpeed replace the reused InvalidCalibration. - CandidateCost::source says analytical, measured or mixed. - Move the KLL failure-probability lookup test from empirical_cost; document that near-miss parameters are priced from the formulas. - Drop NodePriceSource's unused Default; name detail-text locals. --- .../asap-aware-mapping/src/analytical_cost.rs | 13 ++ .../src/stage3_plan_selection.rs | 209 +++++++++++++++--- .../measured_operation_costs.rs | 14 +- 3 files changed, 193 insertions(+), 43 deletions(-) diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index 3a029619d..fd4499666 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -2165,6 +2165,19 @@ pub enum AnalyticalCostError { }, #[error("invalid physical DAG: {0}")] InvalidPhysicalDAG(&'static str), + /// Bad input: plan selection's measured table has two entries for one + /// sketch configuration and operation. + #[error("measured_operation_costs has two entries for {operation:?} on {sketch:?}")] + DuplicateMeasuredOperationCost { + sketch: SketchParams, + operation: crate::stage3_plan_selection::SummaryOperation, + }, + /// Bad input: a non-empty measured table with `ms_per_cpu_op` not + /// positive, so a measured time cannot be counted in CPU operations. + #[error( + "measured_operation_costs needs a positive ms_per_cpu_op to count measured times as operations, got {0}" + )] + MeasuredCostsWithoutCpuSpeed(f64), } fn checked_bytes(parts: &[u64]) -> Result { diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection.rs b/crates/asap-aware-mapping/src/stage3_plan_selection.rs index bf0304844..b8ce2ac3a 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection.rs @@ -98,8 +98,13 @@ //! (one instance per group), merge or estimate with an entry is priced //! from it: rows × measured updates (plus the one placement operation per //! row of a pane chain), states × measured merges, groups × measured -//! estimates. Every other node is priced from the formulas above, and each -//! node reports which ([`NodeCost::price_source`]). Measured times count as +//! estimates. The newest window's compaction (see "Latency") merges with +//! the state's measured merge too. Every other node is priced from the +//! formulas above, and each node reports which ([`NodeCost::price_source`]). +//! An entry matches only a node with exactly its sketch parameters (up to +//! the error and failure probability the sketch was sized for): a node whose +//! parameters differ, however slightly, is priced from the formulas, with no +//! interpolation between measured configurations. Measured times count as //! CPU operations of [`ResourceSpeed`], so latency and all three cost //! models read them like any other operation. //! @@ -143,7 +148,8 @@ //! and scanned bytes timed by [`ResourceSpeed`]'s `ms_per_cpu_op` and //! `ms_per_scanned_byte`. A state whose ingestion work needs `k` cores keeps //! `k` partial copies of its newest window, and compacting them takes -//! `(k − 1)` one-operation merges for each of its states per window; with +//! `(k − 1)` merges for each of its states per window, each one operation, +//! or the state's measured merge time when the measured table has one; with //! one core there is nothing to compact. A query with //! `Some(bound)` whose latency is above the //! bound makes the candidate invalid, with the latency as the reason. @@ -210,7 +216,15 @@ const DEFAULT_LOOKBACK_MS: u64 = 60_000; /// Cost per second of wall time; one unit is one CPU-millisecond under /// [`ResourceUnitCost::illustrative`]. pub const COST_UNIT: &str = "cost_per_second"; +/// [`CandidateCost::source`] when every node is priced from the formulas. pub const COST_SOURCE: &str = "analytical-cost-v2 (illustrative statistics)"; +/// [`CandidateCost::source`] when every node is priced from the measured +/// table. +pub const MEASURED_COST_SOURCE: &str = "measured operation costs (illustrative statistics)"; +/// [`CandidateCost::source`] when some nodes are priced from the measured +/// table and the others from the formulas. +pub const MIXED_COST_SOURCE: &str = + "measured operation costs and analytical-cost-v2 (illustrative statistics)"; /// Seconds over which non-repeating queries (one-off, scheduled and /// unknown) are spread by default: one hour. @@ -359,8 +373,8 @@ pub struct PlanSelectionSettings { pub non_repeating_query_horizon_seconds: f64, /// The deployment's measured CPU time per sketch operation; a sketch /// node with an entry is priced from it, every other node from the - /// formulas ([`measured_operation_costs`]). Default empty: every node - /// is priced from the formulas. + /// formulas (module doc, "Measured operation costs"). Default empty: + /// every node is priced from the formulas. pub measured_operation_costs: Vec, } @@ -463,6 +477,9 @@ pub struct CandidateCost { /// sum of its nodes'); `None` under cost model 3. pub cost_per_second: Option, pub unit: &'static str, + /// Where its prices came from, from its nodes' [`NodeCost::price_source`]: + /// [`COST_SOURCE`] (all formulas), [`MEASURED_COST_SOURCE`] (all + /// measured) or [`MIXED_COST_SOURCE`]. pub source: &'static str, pub per_node: BTreeMap, } @@ -2212,7 +2229,8 @@ fn price( rows: group_count, bytes: group_count * state_bytes, }; - let (ops, per_row) = match measured_ops(node_sketch, SummaryOperation::Update) { + let (ops, per_row_text) = match measured_ops(node_sketch, SummaryOperation::Update) + { Some(per_update) => { price_source = NodePriceSource::Measured; ( @@ -2226,7 +2244,7 @@ fn price( out, Ok(ResourceEstimate::new(ops, out.bytes, 0)), format!( - "build {} into {group_count} states: {} rows x {per_row}", + "build {} into {group_count} states: {} rows x {per_row_text}", family_name(family), input.rows ), @@ -2249,7 +2267,8 @@ fn price( rows: states, bytes: states * state_bytes, }; - let (ops, per_row) = match measured_ops(node_sketch, SummaryOperation::Update) { + let (ops, per_row_text) = match measured_ops(node_sketch, SummaryOperation::Update) + { Some(per_update) => { price_source = NodePriceSource::Measured; ( @@ -2267,7 +2286,7 @@ fn price( Ok(ResourceEstimate::new(ops, out.bytes, 0)), format!( "build {} in {pane_count} panes of {pane_width_ms} ms for \ - {group_count} groups: {} rows x {per_row}", + {group_count} groups: {} rows x {per_row_text}", family_name(family), input.rows ), @@ -2293,7 +2312,8 @@ fn price( rows: group_count, bytes: input.bytes.div_ceil(input.rows.max(1)) * group_count, }; - let (ops, measured) = match measured_ops(node_sketch, SummaryOperation::Merge) { + let (ops, per_merge_text) = match measured_ops(node_sketch, SummaryOperation::Merge) + { Some(per_merge) => { price_source = NodePriceSource::Measured; ( @@ -2306,7 +2326,7 @@ fn price( ( out, Ok(ResourceEstimate::new(ops, out.bytes, 0)), - format!("merge {states} states into {group_count}{measured}"), + format!("merge {states} states into {group_count}{per_merge_text}"), ) } Payload::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { @@ -2317,20 +2337,21 @@ fn price( let out = edge(input.rows * per_group); // A measured estimate reads one group's result, all of a // top-k's `k` entries included. - let (ops, per_state) = match measured_ops(node_sketch, SummaryOperation::Estimate) { - Some(per_estimate) => { - price_source = NodePriceSource::Measured; - ( - input.rows as f64 * per_estimate, - format!("{per_estimate:.3} measured ops per estimate"), - ) - } - None => (out.rows as f64, per_group.to_string()), - }; + let (ops, per_group_text) = + match measured_ops(node_sketch, SummaryOperation::Estimate) { + Some(per_estimate) => { + price_source = NodePriceSource::Measured; + ( + input.rows as f64 * per_estimate, + format!("{per_estimate:.3} measured ops per estimate"), + ) + } + None => (out.rows as f64, per_group.to_string()), + }; ( out, Ok(ResourceEstimate::new(ops, 0, 0)), - format!("estimate {} groups x {per_state}", input.rows), + format!("estimate {} groups x {per_group_text}", input.rows), ) } Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) => ( @@ -2481,7 +2502,16 @@ fn price( measured.detail += &format!("; {copies} partial copies of its newest window, {bytes} bytes"); } - let latency = query_latencies(dag, nodes, &output, &work_ms, &speed); + // Milliseconds of one merge of each state with a measured merge entry + // (measured ns · 10⁻⁶), for the newest window's compaction. + let merge_ms: HashMap = sketch_of + .iter() + .filter_map(|(id, sketch)| { + measured_ops(Some(sketch), SummaryOperation::Merge) + .map(|ops| (*id, ops * speed.ms_per_cpu_op)) + }) + .collect(); + let latency = query_latencies(dag, nodes, &output, &work_ms, &merge_ms, &speed); // A MatAtQuery state is held for one evaluation: from its build until // the longest query reading it finishes, about that query's latency. let reach = query_reach(dag, nodes); @@ -2523,11 +2553,23 @@ fn price( usage, cost_per_second: cost, unit: COST_UNIT, - source: COST_SOURCE, + source: cost_source(&per_node), per_node, }) } +/// [`CandidateCost::source`] for nodes priced as `per_node` says. +fn cost_source(per_node: &BTreeMap) -> &'static str { + let measured = |node: &NodeCost| node.price_source == NodePriceSource::Measured; + if !per_node.values().any(measured) { + COST_SOURCE + } else if per_node.values().all(measured) { + MEASURED_COST_SOURCE + } else { + MIXED_COST_SOURCE + } +} + /// The CPU cores a candidate needs at its peak: enough for its /// ingestion-time work every second (none without any), plus one per /// query with query-time work, all evaluating at once; at least the cores @@ -2558,14 +2600,16 @@ fn peak_cpu_cores( /// the query-time work it reaches, each node once, plus the longest /// compaction among the ingestion-time states it reads. Compacting a /// state's newest window merges, for each of its `I` states per window, -/// the `k` ingest workers' partial copies, `(k − 1)·I` merges of one -/// operation each, where `k` is the cores its ingestion work takes, at -/// least one. +/// the `k` ingest workers' partial copies, `(k − 1)·I` merges, where `k` +/// is the cores its ingestion work takes, at least one. Each merge takes +/// the state's measured merge time (`merge_ms`) when the measured table has +/// one, and one operation otherwise. fn query_latencies( dag: &PhysicalASAPDAG, nodes: &HashMap, output: &HashMap, work_ms: &HashMap, + merge_ms: &HashMap, speed: &ResourceSpeed, ) -> Vec { let inputs = |id: PhysicalASAPNodeId| { @@ -2579,7 +2623,8 @@ fn query_latencies( let compaction_ms = |state: PhysicalASAPNodeId| { let workers = ingest_workers(dag, work_ms, state) as f64; let states = (output[&state].rows / panes_of(nodes[&state])) as f64; - (workers - 1.0) * states * speed.ms_per_cpu_op + let per_merge_ms = merge_ms.get(&state).copied().unwrap_or(speed.ms_per_cpu_op); + (workers - 1.0) * states * per_merge_ms }; query_starts(dag) .into_iter() @@ -5702,6 +5747,54 @@ mod tests { ); } + /// The newest window's compaction merges the `k` workers' partial + /// sketches with the state's measured merge when the table has one: + /// a 1,200 ns KLL merge makes each of the `(k − 1)·I` merges 1,200 + /// operations of 1 ns instead of one, and nothing else in the + /// compaction changes. + #[test] + fn compaction_prices_merges_from_the_measured_merge() { + let candidates = pane_candidates(1e10); + let dag = &candidates[1].dag; + let kll = dag + .nodes + .iter() + .find_map(|n| match &n.payload { + Payload::ASAP( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + } + | ASAPOp::PaneChain { + family: FieldDataType::Sketch(kind, _), + .. + }, + ) => Some(kind.params().clone()), + _ => None, + }) + .expect("a KLL build"); + let latency = |settings: &PlanSelectionSettings| { + price(dag, &[repeating(MINUTE)], &arriving(1e10), settings) + .unwrap() + .usage + .latency_per_query[0] + }; + let analytical = latency(&PlanSelectionSettings::default()); + assert!(analytical.compaction_ms > 0.0, "{analytical:?}"); + let measured = latency(&PlanSelectionSettings { + measured_operation_costs: vec![MeasuredOperationCost { + sketch: kll, + operation: SummaryOperation::Merge, + cpu_ns_per_operation: 1200.0, + }], + ..PlanSelectionSettings::default() + }); + assert!( + close(measured.compaction_ms, 1200.0 * analytical.compaction_ms), + "{analytical:?} vs {measured:?}" + ); + } + // ── Cost models ────────────────────────────────────────────────────── /// [`PlanSelectionSettings`] with `cost_model`. @@ -6167,6 +6260,8 @@ mod tests { .detail .contains("250.000 measured ops per update")); assert!(after.cost_per_second.unwrap() > before.cost_per_second.unwrap()); + assert_eq!(before.source, COST_SOURCE); + assert_eq!(after.source, MIXED_COST_SOURCE); // The build's CPU is rows × measured ns, counted in operations of // `ms_per_cpu_op`: the candidate's CPU operations grow linearly in // the measured time, (cpu(250) - cpu(100)) / (cpu(400) - cpu(100)) @@ -6191,6 +6286,33 @@ mod tests { )); } + /// A planner-sized KLL carries the failure probability it was sized for; + /// the converter's entry has none. They are the same configuration, so + /// the entry matches, while another `k` does not (no interpolation). + #[test] + fn lookup_ignores_the_kll_failure_probability() { + let table = [MeasuredOperationCost { + sketch: SketchParams::Kll { + k: 331, + failure_probability: None, + }, + operation: SummaryOperation::Update, + cpu_ns_per_operation: 85.0, + }]; + let sized = |k| SketchParams::Kll { + k, + failure_probability: Some(0.001), + }; + assert_eq!( + measured_cpu_ns(&table, &sized(331), SummaryOperation::Update), + Some(85.0) + ); + assert_eq!( + measured_cpu_ns(&table, &sized(330), SummaryOperation::Update), + None + ); + } + /// With no measured entry for its sketch, or an entry for another /// operation, every node is priced from the formulas, as without a table. #[test] @@ -6237,19 +6359,36 @@ mod tests { operation: SummaryOperation::Update, cpu_ns_per_operation, }; - for (table, ms_per_cpu_op) in [ - (vec![entry(-1.0)], 1e-6), - (vec![entry(f64::NAN)], 1e-6), - (vec![entry(1.0), entry(2.0)], 1e-6), - (vec![entry(1.0)], 0.0), - ] { + let validate = |table, ms_per_cpu_op| { let mut settings = PlanSelectionSettings { measured_operation_costs: table, ..PlanSelectionSettings::default() }; settings.resource_speed.ms_per_cpu_op = ms_per_cpu_op; - assert!(settings.validate().is_err()); + settings.validate() + }; + for ns in [-1.0, f64::NAN] { + assert!(matches!( + validate(vec![entry(ns)], 1e-6), + Err(AnalyticalCostError::InvalidCalibration(..)) + )); } + let duplicate = validate(vec![entry(1.0), entry(2.0)], 1e-6).unwrap_err(); + assert_eq!( + duplicate, + AnalyticalCostError::DuplicateMeasuredOperationCost { + sketch: entry(1.0).sketch, + operation: SummaryOperation::Update, + } + ); + assert!( + duplicate.to_string().contains("Update on Cms"), + "{duplicate}" + ); + assert_eq!( + validate(vec![entry(1.0)], 0.0), + Err(AnalyticalCostError::MeasuredCostsWithoutCpuSpeed(0.0)) + ); let mut empty = PlanSelectionSettings::default(); empty.resource_speed.ms_per_cpu_op = 0.0; assert!( diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs b/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs index 456ebe247..b5abeba27 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection/measured_operation_costs.rs @@ -52,11 +52,10 @@ pub struct MeasuredOperationCost { } /// Which prices one node's CPU operations came from. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum NodePriceSource { /// Plan selection's formulas (`analytical_cost` and the sketch shapes). - #[default] Analytical, /// The entry of [`MeasuredOperationCost`] for its sketch and operation. Measured, @@ -83,8 +82,7 @@ pub(super) fn validate( ms_per_cpu_op: f64, ) -> Result<(), AnalyticalCostError> { if !table.is_empty() && ms_per_cpu_op <= 0.0 { - return Err(AnalyticalCostError::InvalidCalibration( - "ms_per_cpu_op (measured_operation_costs needs it positive)", + return Err(AnalyticalCostError::MeasuredCostsWithoutCpuSpeed( ms_per_cpu_op, )); } @@ -100,10 +98,10 @@ pub(super) fn validate( .iter() .any(|earlier| earlier.operation == entry.operation && earlier.sketch == entry.sketch) { - return Err(AnalyticalCostError::InvalidCalibration( - "measured_operation_costs: two entries for one sketch and operation", - ns, - )); + return Err(AnalyticalCostError::DuplicateMeasuredOperationCost { + sketch: entry.sketch.clone(), + operation: entry.operation, + }); } } Ok(())