Repository navigation
feat(planner)!: Stage 3 resource usage and cost models 1-3 (usage over time, provisioned capacity, own cluster) - #723
Merged
Merged
Conversation
zzylol
force-pushed
the
feat/stage3-cost-models
branch
from
October 15, 2026 23:30
4ca39b8 to
e9977ec
Compare
Plan selection measures each valid candidate's ResourceUsage (CPU operations and scanned bytes per second, average and peak memory, disk, peak CPU cores, per-query latency; per node NodeUsage) without prices, and the deployment's CostModel chooses among the candidates: - UsageOverTime (cost model 1, the default): unit cost x usage per second, the cheapest wins; it reproduces the previous prices exactly. - ProvisionedCapacity (cost model 2): unit cost x peak memory, peak CPU cores and disk, the cheapest wins. - ClusterCapacity (cost model 3): memory, disk and optional cores are hard limits; lowest latency, least memory or the Pareto front chooses. ResourceUnitCost replaces Stage 3's use of ResourceCalibration; the speeds move to ResourceSpeed and the non-repeating query horizon to PlanSelectionSettings, which PlanningModels carries (plan_selection) and select_plan validates once (SelectionError::InvalidSettings). ResourceCalibration keeps only the legacy core's fields. stage_pipeline takes --cost-model and the --cluster-* flags and reports each shown candidate's usage, cost_per_second and the Pareto front; the viewer accepts a null cost (cost model 3).
- Cost model 3 compares peak memory and disk as whole bytes, exactly, as dominance does; latency (and price) still tie within rounding. Under the Pareto front only a front member can be selected. Before, peak memory within 1e-9 counted as a tie, so a candidate dominated by a few bytes could be selected off the front (and win under least memory). - The partial copies' window is the kept state per window (output or an exact aggregate's values), as the kept memory uses. - The private horizon parameters are non_repeating_query_horizon_seconds.
zzylol
force-pushed
the
feat/stage3-cost-models
branch
from
October 16, 2026 00:09
e9977ec to
c00a063
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Advances #683 (cost models 1 and 2, and Example 4's different choices) and #660 (cost model 3).
Why
Plan selection (Stage 3) had one cost formula: price CPU operations, scanned bytes and kept-state byte-seconds per second, and take the cheapest valid candidate. That fits pay-as-you-go billing (cost model 1). It does not fit a deployment that pays for provisioned capacity (cost model 2, #683), or one that runs on its own cluster with fixed memory and disk and wants the lowest latency or a trade-off (cost model 3, #660). Prices, speeds and the one-off horizon also lived in one struct (
ResourceCalibration), so a model that has no prices could not be expressed.What
ResourceUsage). Each node has the fields that add up over nodes (NodeUsage).CostModel) inPlanSelectionSettings, whichPlanningModelscarries asplan_selection:UsageOverTime(cost model 1, the default): unit cost × usage per second; the cheapest wins. With the illustrative unit costs it reproduces every previous price bit for bit.ProvisionedCapacity(cost model 2): unit cost × peak memory, peak CPU cores and disk; the cheapest wins.ClusterCapacity(cost model 3): no prices. Memory, disk and (optionally) CPU cores are hard limits, so a candidate over one is invalid with the reason. The objectiveLowestLatency,LeastMemoryorParetoFrontchooses among the rest;Selection.frontlists the Pareto front.stage_pipeline --cost-model ...exposes the choice. Its document now reports each shown candidate'susageand thefront. The candidate'stotaland each node'scostare renamedcost_per_second, as in Rust. A candidate's is null under cost model 3; a node's is null under cost models 2 and 3, which price or limit the whole candidate's peaks. The viewer accepts a null.run_planning_stages_over,stage_pipeline) setsplan_selectiontoday. The planner crate andStage1ToStage3Passrun with the default, cost model 1.How
pricewalks the physical DAG as before, but for each node it records usage instead of a price:Medium::Disk, plus the raw data query-time scans read: per source, the longest window scanned. This changes whatMedium::Diskmeans. Onmain,MatAtQuery(Disk)andMatAtIngestion(Disk)were priced as memory (retained byte-seconds); now their bytes are disk at the disk price and add no memory. Stage 2 places nothing on disk yet, so no output changes.costasNone.select_planvalidates the settings once, before any candidate (SelectionError::InvalidSettings). It checks only what the chosen model reads: the speed, the horizon, and cost model 1's usage prices or cost model 2's provisioned and disk prices. A price the model does not read is not checked. If every price the model reads is 0, the settings are rejected, so cost model 1 with only provisioned prices set does not price everything at 0.LowestLatencycompares worst-query latency, then peak memory, then disk.LeastMemorycompares peak memory, then disk, then latency.ParetoFrontkeeps the candidates that no other candidate beats on all three, and selects the front's lowest-latency member, choosing only among front members.horizon_swent: it is the horizon in seconds over which Stage 3 spreads non-repeating queries (one-off, scheduled and unknown). It is nowPlanSelectionSettings::non_repeating_query_horizon_seconds, next to the cost model and speed, because it is a deployment choice for plan selection and not a price.stage3_plan_selection::CostModel. The legacypub trait CostModelstays atcost_model::CostModel, re-exported at the crate root. No module imports both, so neither one needed a path or a rename.ResourceCalibrationstays for the legacy core (PhysicalPlanCostModel) only. The fields only Stage 3 read are deleted, as isDEFAULT_HORIZON_S.Key code interfaces added / modified
crates/asap-aware-mapping/src/stage3_plan_selection.rscrates/asap-aware-mapping/src/pass/mod.rsOnly devtools calls
with_plan_selectiontoday (run_planning_stages_over, sostage_pipeline). The planner crate's passes usePlanningModels::builtin(), which is cost model 1.crates/asap-aware-mapping/src/analytical_cost.rscrates/devtools/src/planning_stages.rscrates/devtools/src/bin/stage_pipeline.rs: new flags--cost-model usage-over-time|provisioned-capacity|cluster-capacity,--cluster-memory-bytes,--cluster-disk-bytes,--cluster-cpu-cores,--cluster-objective lowest-latency|least-memory|pareto-front. The document gainsstage3_plan_selection.costs.<id>.usage(cpu_ops_per_second,scanned_bytes_per_second,average_memory_bytes,peak_memory_bytes,disk_bytes,peak_cpu_cores,latency_per_queryas[{compaction_ms, query_ms}]) andstage3_plan_selection.front. A candidate'stotaland a node'scostare renamedcost_per_secondand are null under cost model 3.Usage, end to end
#509 Example 4, Pattern B, planned under each cost model. This was compiled and run as a test at the head commit, and every assertion passed.
The same from the command line:
Before this PR
Plan selection had a single cost formula: cost model 1 with the illustrative prices, fixed in code (
illustrative_calibration()). A deployment could not say it pays for provisioned capacity, or that it has a fixed cluster. Only the scalartotaland the latency were reported, not the memory, disk and cores behind them. On Example 4 every deployment got P2 (the whole-window KLL).After this PR
The default (cost model 1) is unchanged. The selected totals are bit-identical: Example 1 P1 7.94021, Example 2 P1 6.0800009, Example 3a P485 39,828.80278, Example 3b/4 P2 2.1. Every candidate's cost and every node's cost were compared against
stage_pipelinebuilt atmain(f4a5b98), with no display cap, for all four examples: 171 candidates and 3,196 nodes, 0 differences, and the same selections and rejections.The memory figures above come from each KLL's full capacity, not from the samples it holds. They are overstated for this workload, and the cost model 2 and 64 GB results depend on them (see Limitations, #726).
Verification
At head 014b5b2, rebased on
mainf4a5b98 (with #722):cargo nextest run -p asap-aware-mapping -p asap-devtools -p asap-integration-tests -p asap-planner: 930 passed, 29 skipped. New tests:node_usage_adds_up_to_the_candidatesprovisioned_capacity_prices_peak_memory_cores_and_diskcluster_capacity_rejects_a_candidate_over_its_corescluster_capacity_rejects_a_candidate_over_its_diskstate_on_disk_is_counted_as_disk_not_memorycluster_objectives_keep_the_earlier_of_tied_candidatespartial_copies_of_the_newest_window_add_to_peak_memoryraw_data_on_disk_is_the_longest_window_per_sourcedominance_needs_one_strictly_better_resourceinvalid_settings_are_rejected, which covers cost model 1 with only provisioned prices.memory_differing_by_a_few_bytes_is_compared_exactly. Two candidates with equal latency and disk, at 124,000,000,100 and 124,000,000,000 bytes: the front is the smaller one, and the Pareto front, least memory and lowest latency all select it. This test failed before the fix: the larger one was selected, off the front.memory_held_only_while_a_query_runs_is_not_chargednow variescost_per_memory_byte_second, the price cost model 1 reads. It asserts that the memory term equals average memory while peak memory is larger, so it can fail.stage3_plan_selection_b_cost_models_choose_differentlyandstage3_plan_selection_b_pareto_front_over_memory_disk_and_latency.stage_pipeline:cost_model_flag_chooses_how_candidates_are_comparedandcluster_flags_and_cost_model_3_go_together.main(f4a5b98): 171 candidates, 3,196 nodes, 0 differences, same selections and rejections (see After).python3 -m unittest test_viewer(tools/dag-viewer): 35 passed with a builtstage_pipeline. Without one, 4 are skipped (31 passed, 4 skipped, as in the review's run).cargo fmt --all -- --checkandcargo clippy --workspace --all-targets --all-features --locked -- -D warnings: clean.tools/verify_metricsql_external_consumer.sh: clean at the first head.Deviations from the approved design
CandidateCost.per_nodekeepsNodeCost(now holdingusage: NodeUsageandcost_per_second: Option<f64>) instead of a bareResourceUsage.stage_pipelineand the viewer show each node'smultiplieranddetail.ResourceUsage.latency_per_query: Vec<QueryLatency>keeps the compaction/query split that the latency rejection reason reports, instead oflatency_ms: Vec<f64>. The JSON uses the same name and split.ResourceUsage.peak_cpu_coresandResourceUnitCost.cost_per_provisioned_cpu_core_secondare added, because cost model 2 prices cores and cost model 3 limits them._second(cost_per_disk_byte_second,cost_per_provisioned_memory_byte_second), because every price is per second of wall time.CostModel: no clash with the legacy trait, see How.horizon_smoved toPlanSelectionSettings::non_repeating_query_horizon_seconds, a deployment setting for plan selection, rather than to the workload. It spreads one-off, scheduled and unknown queries, hence the name.ResourceUnitCost.version(in the first head) is deleted: nothing outside tests read it. The legacyResourceCalibration.versionstays.NodeUsage, without the cores and latency that only a whole candidate has.Limitations and follow-ups
Sketch memory is each sketch's full capacity. A KLL state is sized
8 × kll_items(k)over all 61 levels: 17,768 bytes per state in Example 4. This mirrors the pinnedasap_sketchlibKLL, which allocates that capacity up front. But each Pattern B series has only 20 samples per 5-minute window and 4 per 1-minute pane. For the values held, P2's "17.8 GB" would be 160 MB and B1's "107 GB" kept would be 192 MB. So cost model 2 selecting P1, cost model 3 at 64 GB rejecting B1 and B2, and cost model 1's 13,326.53/s for B1 all depend on this overstatement. Capping a state's size by the samples it holds, together with a growing implementation (KLLDynamic, or lazy allocation upstream), is [Bug] Stage 3 sizes a sketch's memory by its full capacity, not by the samples it holds #726.The illustrative unit costs are not calibrated. Disk is unpriced (0), and the provisioned core price (1,000/s, one core-second) makes cost model 2 rank mostly by cores and then memory. Plan selection cost models: usage over time (cost model 1), peak capacity (cost model 2), own-cluster trade-offs (cost model 3) #683's billing-survey presets remain open.
The viewer hides cost model 3's reasons. It lists every valid candidate that was not selected as "costlier" without a reason, so "slower", "more memory", "dominated by …" and "not its lowest-latency member" appear only in the JSON's
rejected. It also shows no ranking or usage under cost model 3.Peak memory and peak cores assume every evaluation overlaps. Overlapping evaluations of one query (latency above its interval) are not counted. The k − 1 partial copies built by k ingest workers are counted in peak memory only. Example 4 has one worker per state, so its figures do not change.
Raw-data disk counts each source's longest query-time window. Retention policy beyond that is not modelled.
PlanOutputand the viewer do not show usage or the front yet (G11). Under cost model 3 the viewer shows "no price" and no ranking.A selection error under cost model 3 still says "no valid candidate", with each candidate's limit reason.