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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions crates/devtools/src/bin/stage_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<json>'` declares what the deployment's
Expand Down Expand Up @@ -141,7 +144,7 @@ use serde_json::{json, Value};
const USAGE: &str =
"usage: stage_pipeline (--example planner-layering-{1,2,3a,3b} | ((--promql | --sql) <query> \
[--name <name>]... [--table <json>...] [--max-error <f64> --failure-probability <f64> --accuracy-metric <metric>] [--sample-interval-ms <u64>])) \
[--cost-model usage-over-time|provisioned-capacity|cluster-capacity [--cluster-memory-bytes <n> \
[--cost-model usage-over-time|aws-fargate-us-east-1|provisioned-capacity|cluster-capacity [--cluster-memory-bytes <n> \
--cluster-disk-bytes <n> [--cluster-cpu-cores <n>] --cluster-objective lowest-latency|least-memory|pareto-front]] \
[--deployment-capabilities <json>] [--max-combinations <n>] [--max-displayed <n>] --out <file>";

Expand Down Expand Up @@ -297,10 +300,18 @@ fn run(args: Vec<String>) -> 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())
}
Expand All @@ -324,7 +335,7 @@ fn run(args: Vec<String>) -> 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()),
Expand All @@ -342,7 +353,7 @@ fn run(args: Vec<String>) -> 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) => {
Expand Down
37 changes: 37 additions & 0 deletions crates/devtools/tests/stage_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 120 additions & 5 deletions crates/plan-selection/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://aws.amazon.com/fargate/pricing/>, 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
};
Expand All @@ -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 => (
Expand Down Expand Up @@ -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<u64> {
values_per_result(root, series_read(root, data))
Expand Down
Loading