diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 20b20f88..f586c982 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -75,7 +75,10 @@ // `cluster-capacity` (cost model 3: no prices; `--cluster-memory-bytes`, // `--cluster-disk-bytes` and the optional `--cluster-cpu-cores` are hard // limits, and `--cluster-objective` is `lowest-latency`, `least-memory` or -// `pareto-front`). Cost models 1 and 2 use the illustrative unit costs. +// `pareto-front`). Cost models 1 and 2 use the illustrative unit costs; +// `aws-fargate-us-east-1` is cost model 1 at AWS Fargate's us-east-1 +// Linux/x86 prices (`ResourceUnitCost::aws_fargate_us_east_1`, in US dollars +// per second). // Each shown candidate's `usage` lists what it uses; `cost_per_second` is null under // cost model 3, and `front` lists the Pareto front under `pareto-front`. // `--deployment-capabilities ''` declares what the deployment's @@ -141,7 +144,7 @@ use serde_json::{json, Value}; const USAGE: &str = "usage: stage_pipeline (--example planner-layering-{1,2,3a,3b} | ((--promql | --sql) \ [--name ]... [--table ...] [--max-error --failure-probability --accuracy-metric ] [--sample-interval-ms ])) \ -[--cost-model usage-over-time|provisioned-capacity|cluster-capacity [--cluster-memory-bytes \ +[--cost-model usage-over-time|aws-fargate-us-east-1|provisioned-capacity|cluster-capacity [--cluster-memory-bytes \ --cluster-disk-bytes [--cluster-cpu-cores ] --cluster-objective lowest-latency|least-memory|pareto-front]] \ [--deployment-capabilities ] [--max-combinations ] [--max-displayed ] --out "; @@ -297,10 +300,18 @@ fn run(args: Vec) -> Result<(), Failure> { } let out = out.ok_or("--out is required")?; let cluster = (cluster_memory, cluster_disk, cluster_objective.as_deref()); + // The settings every flag below leaves at its default; the Fargate + // preset converts its prices at their `resource_speed`. + let default_settings = PlanSelectionSettings::default(); let cost_model = match (cost_model.as_deref(), cluster) { (None | Some("usage-over-time"), (None, None, None)) if cluster_cores.is_none() => { CostModel::UsageOverTime(ResourceUnitCost::illustrative()) } + (Some("aws-fargate-us-east-1"), (None, None, None)) if cluster_cores.is_none() => { + CostModel::UsageOverTime(ResourceUnitCost::aws_fargate_us_east_1( + default_settings.resource_speed, + )) + } (Some("provisioned-capacity"), (None, None, None)) if cluster_cores.is_none() => { CostModel::ProvisionedCapacity(ResourceUnitCost::illustrative()) } @@ -324,7 +335,7 @@ fn run(args: Vec) -> Result<(), Failure> { .into(), ) } - (None | Some("usage-over-time" | "provisioned-capacity"), _) => { + (None | Some("usage-over-time" | "aws-fargate-us-east-1" | "provisioned-capacity"), _) => { return Err("--cluster-* flags need --cost-model cluster-capacity".into()) } (Some(other), _) => return Err(format!("unknown cost model {other}").into()), @@ -342,7 +353,7 @@ fn run(args: Vec) -> Result<(), Failure> { let plan_selection = PlanSelectionSettings { cost_model, measured_operation_costs, - ..PlanSelectionSettings::default() + ..default_settings }; let mut document = match (example.as_deref(), queries.is_empty()) { (Some(example), true) => { diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 5d50eef1..e7a4d514 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -712,6 +712,43 @@ fn cost_model_flag_chooses_how_candidates_are_compared() { assert!(cluster["costs"]["P3-m1"]["cost_per_second"].is_null()); } +/// `--cost-model aws-fargate-us-east-1` prices Example 4 under cost model 1 +/// at AWS Fargate's us-east-1 prices, in US dollars per second: the +/// whole-window KLL (P2) is still selected, at $2.36124e-8/s (its 2.1 +/// CPU-ms/s at $1.1244e-8 per CPU-ms; it keeps no memory), and the panes +/// maintained at ingestion time (P3-m1) cost about $1.2262e-4/s, almost all +/// for their 106.6 GB kept in memory. A costlier candidate's reason shows +/// such small costs in scientific notation. +#[test] +fn aws_fargate_preset_prices_example_4_in_dollars() { + let document = generate_example( + "planner-layering-3b", + &["--cost-model", "aws-fargate-us-east-1"], + ); + let selection = &document["stage3_plan_selection"]; + assert_eq!(selection["selected"], "P2"); + let cost = |id: &str| selection["costs"][id]["cost_per_second"].as_f64().unwrap(); + let close = |actual: f64, expected: f64, tolerance: f64| { + assert!( + ((actual - expected) / expected).abs() < tolerance, + "{actual} != {expected}" + ) + }; + close(cost("P2"), 2.36124e-8, 1e-9); + close(cost("P3-m1"), 1.2262e-4, 1e-4); + // A dollar cost below 0.001 is shown in scientific notation, not as 0.000. + let p1 = selection["rejected"] + .as_array() + .unwrap() + .iter() + .find(|r| r["id"] == "P1") + .unwrap(); + assert_eq!( + p1["reason"], + "costlier: 2.699e-8 vs 2.361e-8 cost_per_second" + ); +} + /// `--deployment-capabilities` declares the executor's capabilities, which /// the document's `deployment_capabilities` shows (defaults filled in; /// without the flag, every summary supported, raw data retained, no diff --git a/crates/plan-selection/src/lib.rs b/crates/plan-selection/src/lib.rs index 19d98bd4..c0f1e55e 100644 --- a/crates/plan-selection/src/lib.rs +++ b/crates/plan-selection/src/lib.rs @@ -316,6 +316,60 @@ impl ResourceUnitCost { cost_per_disk_byte_second: 0.0, } } + + /// AWS Fargate on-demand prices, US East (N. Virginia), Linux/x86, for + /// cost model 1 ([`CostModel::UsageOverTime`]); one cost unit is one + /// US dollar. Source: , checked + /// 2026-10-18 (Pricing Example 1 there: $0.000011244 per vCPU-second, + /// $0.000001235 per GB-second of memory, $0.0000000308 per GB-second of + /// ephemeral storage beyond the 20 GB included). Prices change: re-check + /// the page before relying on them. + /// + /// `speed` is the deployment's [`PlanSelectionSettings::resource_speed`], + /// the speed Stage 3 uses for latency, so the CPU prices charge the core + /// time latency counts. At [`ResourceSpeed::illustrative`] (1e-6 ms per + /// CPU operation, i.e. 1e9 operations per core-second; 1e-7 ms per + /// scanned byte): + /// + /// * `cost_per_cpu_op` = $0.000011244 per vCPU-second × + /// `ms_per_cpu_op` / 1,000 (1e-9 s per operation) = $1.1244e-14. + /// * `cost_per_scanned_byte` = $0.000011244 per vCPU-second × + /// `ms_per_scanned_byte` / 1,000 (1e-10 s per byte) = $1.1244e-15: + /// Fargate has no scan price, but a scan occupies the vCPU-seconds it + /// bills, for the time latency charges. + /// * `cost_per_memory_byte_second` = $0.000001235 per GB-second ÷ 2^30 + /// bytes per GB ≈ $1.1502e-15: the page says "on this page GB = + /// 1024^3 bytes". + /// * `cost_per_disk_byte_second` = 0: Fargate bills only ephemeral + /// storage configured beyond the 20 GB every task gets, which a + /// per-byte price cannot express, and the disk bytes plan selection + /// counts (the raw data query-time scans read) are held by the data + /// source, not by the task. + /// * `cost_per_provisioned_memory_byte_second` and + /// `cost_per_provisioned_cpu_core_second` = 0: they belong to cost + /// model 2, and Fargate's per-task billing is cost model 1. Under + /// [`CostModel::ProvisionedCapacity`] every price this preset gives + /// is 0, which [`PlanSelectionSettings::validate`] refuses. + /// + /// Not expressed by these per-second prices: the 1-minute billing + /// minimum per task ("Pricing is calculated per second with a 1-minute + /// minimum"), and the fixed task sizes a task is billed for (from 0.25 + /// vCPU with 0.5 GB up), which round usage up to the next size. + pub fn aws_fargate_us_east_1(speed: ResourceSpeed) -> Self { + // US dollars per vCPU-second and per GB-second of memory, from the + // page above. + const DOLLARS_PER_VCPU_SECOND: f64 = 0.000_011_244; + const DOLLARS_PER_MEMORY_GB_SECOND: f64 = 0.000_001_235; + const BYTES_PER_GIB: f64 = (1u64 << 30) as f64; + Self { + cost_per_cpu_op: DOLLARS_PER_VCPU_SECOND * speed.ms_per_cpu_op / 1_000.0, + cost_per_scanned_byte: DOLLARS_PER_VCPU_SECOND * speed.ms_per_scanned_byte / 1_000.0, + cost_per_memory_byte_second: DOLLARS_PER_MEMORY_GB_SECOND / BYTES_PER_GIB, + cost_per_provisioned_memory_byte_second: 0.0, + cost_per_provisioned_cpu_core_second: 0.0, + cost_per_disk_byte_second: 0.0, + } + } } /// How fast one core does work; the latency check reads it. @@ -474,6 +528,18 @@ impl PlanSelectionSettings { } } +/// A cost per second for a rejection reason: three decimals, or three +/// significant digits in scientific notation when it is nonzero and below +/// 0.001 (e.g. "2.361e-8" under [`ResourceUnitCost::aws_fargate_us_east_1`]'s +/// dollars), so small costs do not all read "0.000". +fn display_cost(cost_per_second: f64) -> String { + if cost_per_second != 0.0 && cost_per_second.abs() < 1e-3 { + format!("{cost_per_second:.3e}") + } else { + format!("{cost_per_second:.3}") + } +} + /// One node's price per second of wall time, with how it was measured. A /// node is one operator node of the candidate's physical plan DAG /// (`PhysicalASAPDAG`), keyed by its [`PhysicalASAPNodeId`] in @@ -1035,8 +1101,8 @@ impl Ranking { if !self.better(best, cost) { let tied = match self { Ranking::Price => format!( - "{:.3} {COST_UNIT}", - cost.cost_per_second.unwrap_or_default() + "{} {COST_UNIT}", + display_cost(cost.cost_per_second.unwrap_or_default()) ), _ => describe_usage(&cost.usage), }; @@ -1051,9 +1117,9 @@ impl Ranking { Ranking::Price => ( RejectionCause::Costlier, format!( - "costlier: {:.3} vs {:.3} {COST_UNIT}", - cost.cost_per_second.unwrap_or_default(), - best.cost_per_second.unwrap_or_default() + "costlier: {} vs {} {COST_UNIT}", + display_cost(cost.cost_per_second.unwrap_or_default()), + display_cost(best.cost_per_second.unwrap_or_default()) ), ), Ranking::Latency => ( @@ -3200,6 +3266,55 @@ mod tests { use asap_types::ir::{ExprSemantics, ScalarExpr}; use asap_types::workload::{DataArrival, MetricType, SourceFacts}; + /// The AWS Fargate preset holds the page's prices converted as its doc + /// says at the given speed: per vCPU-second × seconds per operation or + /// per scanned byte (1e-9 and 1e-10 at the illustrative speed; twice + /// that at half the speed), per GB-second ÷ 2^30; disk and the + /// provisioned prices 0. It validates under cost model 1 and is + /// refused under cost model 2, where every price it gives is 0. + #[test] + fn aws_fargate_us_east_1_converts_the_page_prices() { + let preset = ResourceUnitCost::aws_fargate_us_east_1(ResourceSpeed::illustrative()); + let close = |actual: f64, expected: f64| { + assert!( + ((actual - expected) / expected).abs() < 1e-12, + "{actual} != {expected}" + ) + }; + close(preset.cost_per_cpu_op, 1.1244e-14); + close(preset.cost_per_scanned_byte, 1.1244e-15); + close( + preset.cost_per_memory_byte_second, + 1.235e-6 / 1_073_741_824.0, + ); + assert_eq!(preset.cost_per_disk_byte_second, 0.0); + assert_eq!(preset.cost_per_provisioned_memory_byte_second, 0.0); + assert_eq!(preset.cost_per_provisioned_cpu_core_second, 0.0); + // A core twice as slow takes twice the vCPU-seconds per operation and per byte. + let slower = ResourceUnitCost::aws_fargate_us_east_1(ResourceSpeed { + ms_per_cpu_op: 2e-6, + ms_per_scanned_byte: 2e-7, + }); + close(slower.cost_per_cpu_op, 2.2488e-14); + close(slower.cost_per_scanned_byte, 2.2488e-15); + close( + slower.cost_per_memory_byte_second, + preset.cost_per_memory_byte_second, + ); + let settings = |cost_model| PlanSelectionSettings { + cost_model, + ..PlanSelectionSettings::default() + }; + assert_eq!( + settings(CostModel::UsageOverTime(preset.clone())).validate(), + Ok(()) + ); + assert_eq!( + settings(CostModel::ProvisionedCapacity(preset)).validate(), + Err(AnalyticalCostError::ZeroCalibration) + ); + } + /// `G` of `root` over `data` ([`values_per_result`]). fn values_of(root: &QueryRoot, data: &DataWorkload) -> Option { values_per_result(root, series_read(root, data))