Skip to content

feat(bench): measure the data plane's operation costs and feed them to the Planner (Stage E) - #835

Draft
zzylol wants to merge 19 commits into
feat/intern-seriesfrom
feat/stage-e-cost-feedback
Draft

zzylol wants to merge 19 commits into
feat/intern-seriesfrom
feat/stage-e-cost-feedback

Conversation

@zzylol

@zzylol zzylol commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stack 7/7. Base: #834.

Why

Stage 3 predicted 0.07 core-seconds for the plan it selects at s10 with raw data, and the data plane measured 14–16. Its cost inputs price the sketch kernels alone, from sketch-bench, while the runtime pays the operators around them. Design doc, Stage E: feed the runtime's measured costs back to the Planner, and check its predictions against measured runs.

What

  • measure_costs (data_plane binary) measures the data plane's operation costs on this machine, on one core, running the executor's own operators as the data plane runs them:

    • update: the SummaryAgg build per input row (the query-time build);
    • merge: SummaryMerge per input state;
    • estimate: SummaryEstimate per state (p99, or a distinct count).

    It writes them as ASAPPlanner MeasuredOperationCost entries for the sketch configurations of the given plans and of a base table; base entries it does not measure (Count-Min with heap) are kept. Costs the table cannot hold go to --report.

  • control_plane --prediction <file> writes Stage 3's predicted CPU core-seconds, memory and price per second for the selected plan, and how many of its nodes were priced from measured costs. InstallablePlan.selection carries only the price.

  • scripts/bench/cost_feedback.sh measures, writes the deployment's PlanSelectionSettings with the measured table, and plans before and after with predictions.

How

  • Each operation runs as a two-node PhysicalDAG (a source of prepared batches and the operator) through the executor's public API, timed over 1,000 series × 40 samples (6 panes for merges), median of 5 repetitions.
  • The raw-window figure evaluates each raw-reading query of a plan end to end with the data plane's QueryEngine, a fresh engine each time, so no kept pane is reused.
  • The prediction calls asap_planner::e2e_plan with the same inputs as plan_installable, the executor's summary families included, and reads the selected candidate's usage.

Verification

  • cargo test --workspace passes, including e2e_examples (10). New plan_workload::predicts_the_installed_plan_and_prices_from_measured_costs: the prediction names the installed candidate, and a measured cost for its KLL configuration moves its nodes from formula to measured prices.
  • The benefit runs below, with Prometheus alone in the same session.

Measured on this machine (one core, median of 5)

Summary update, query time (ns/row) update, ingestion (ns/sample) merge (ns/state) estimate (ns/state)
exact rate 51 18 194 -
exact sum 49 18 175 -
Kll k=469 170 28 6,511 2,434
Kll k=506 171 28 6,524 2,440
Hll precision=12 95 37 6,623 16,533
Hll precision=14 154 41 22,169 59,906
Hll precision=16 722 470 92,602 238,143
Kll k=50 117 27 3,276 2,011
Kll k=200 141 27 6,146 1,361
Kll k=800 201 28 6,973 2,430
DDSketch alpha=0.005 236 195 2,700 1,620
DDSketch alpha=0.01 208 173 1,808 1,007
DDSketch alpha=0.02 199 162 1,408 697
DDSketch alpha=0.05 192 156 1,047 436
Kll k=25 123 36 2,959 981
Kll k=100 126 27 3,782 4,120
Kll k=400 161 28 6,342 2,411
DDSketch alpha=0.1 192 156 968 371

KLL update came out 4–7× the sketch-bench figure (117–201 ns against 29–31 ns). The kernel matches sketch-bench; the rest is the operator around it: batching, grouping, a state per group. KLL estimate at k ≥ 200 came out 3–20× below sketch-bench, because a merged state is estimated once rather than re-sorted per call.

Raw-reading queries end to end, per raw sample: 0.16 µs at 240,000 samples (p99 over 60 min), 0.61 µs at 20,000 (p99 over 5 min), 3.6 µs at 4,000 (counter queries; mostly the fixed cost of each evaluation).

Stage 3's predictions against the measured run

Predicted data-plane CPU over the 90-minute run, in core-seconds. "Measured" is the data plane on #834 before this PR, running the "before" plan.

Config Predicted before Predicted after Measured
s10 P141-m8: 0.07 P15-m8: 0.08 (0 of 19 nodes measured) 17.0
s10-noraw P195-m40: 0.03 P195-m40: 3.80 (6 of 23 nodes measured) 20.5
s50 P141-m8: 0.36 P15-m8: 0.40 (0 of 19 nodes measured) 70.9
s50-noraw P195-m40: 0.16 P195-m40: 19.03 (6 of 23 nodes measured) 89.4
  • No raw data (noraw): the plan is unchanged (P195-m40). Its 6 KLL nodes now take measured prices, and the prediction rises about 120× (0.03 to 3.8 core-s at s10). It is still about 5× below the measured 19–20 core-s, because everything else is priced by formula (gaps 1–3).
  • Raw data kept: no node of the old plan had a measured price, because its KLL is built per series from raw rows at query time. The measured KLL prices made Stage 3 switch both quantile queries to exact computation over raw samples (P141-m8 to P15-m8). The prediction barely moves (0.07 to 0.08) against the measured 13–17 core-s.

Runs with the re-planned settings

Same 90-minute harness and data-plane binary (#834); only the plan changes. Every evaluation met its accuracy target.

The plan Stage 3 now picks for raw-kept configs cannot run in the data plane. P15-m8's exact quantile_over_time plan fails executor validation with per-entity aggregate requires an explicit entity binding, so the data plane forwarded all 90 quantile evaluations to Prometheus ("forwarded" column). Its lower data-plane CPU (s10 13.4 to 11.2, s50 64.8 to 52.0) is work moved to Prometheus (6.0 to 8.6 and 29.0 to 41.7 core-s). It is not a saving: the total is 18.9 against 19.8 core-s at s10, and 93.8 against 93.7 at s50. The noraw configs keep their plan, and their runs match #834 within noise.

s10

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 18.9 - - (Prometheus 0.10) 9.5 / 19.9 9.9 / 18.2 18.4 / 35.3 84.3 / 140.1 -
#834, settings before 5.5 13.4 0.17 8.5 / 14.7 8.7 / 13.7 32.8 / 58.9 109.1 / 123.0 0
#834, measured settings 8.6 11.2 0.04 10.0 / 11.2 10.2 / 11.1 22.2 / 34.8 107.6 / 138.3 90

s10-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 19.9 - - (Prometheus 0.09) 10.1 / 18.9 10.3 / 18.8 20.9 / 35.7 101.4 / 132.2 -
#834, settings before 6.1 19.1 0.32 9.4 / 19.0 8.0 / 9.9 65.0 / 83.0 94.4 / 118.0 5
#834, measured settings 6.3 19.4 0.34 9.3 / 23.5 8.2 / 10.3 65.6 / 81.3 100.0 / 125.3 5

s50

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 86.7 - - (Prometheus 0.14) 44.2 / 71.7 45.0 / 77.5 93.3 / 140.3 379.9 / 583.4 -
#834, settings before 29.0 64.8 0.30 39.4 / 58.3 33.8 / 54.7 146.3 / 205.6 517.5 / 532.9 0
#834, measured settings 41.7 52.0 0.11 41.7 / 59.2 37.6 / 55.0 104.5 / 150.1 408.3 / 520.6 90

s50-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 85.3 - - (Prometheus 0.14) 42.4 / 74.5 42.1 / 74.5 97.8 / 132.1 418.7 / 643.6 -
#834, settings before 29.4 83.5 0.78 44.4 / 84.2 26.8 / 42.8 262.9 / 302.5 410.0 / 509.9 5
#834, measured settings 29.9 84.3 0.81 45.5 / 83.2 28.3 / 42.7 267.6 / 297.1 460.8 / 550.3 5

What the measured-cost schema cannot express (for an ASAPPlanner issue)

  1. Only sketch operations have a slot. An entry is keyed by SketchParams and one of update, merge, estimate. Exact accumulators (rate, increase, sum, count, min, max) have no SketchParams, so their measured costs cannot be given: update 49–51 ns/row at query time and 18 ns/sample at ingestion; merge 175–194 ns/state. Every exact node stays priced by formula.
  2. Operators other than summaries have no slot: raw-window Scan/TimeRange selection, exact quantile_over_time and other range functions over raw rows, Sort/Limit/Project, joins, the group tables of exact aggregates. Measured end to end per raw sample (raw window plus everything the query does over it): 0.16 µs at 240,000 samples (p99 over 60 min), 0.61 µs at 20,000 (p99 over 5 min), 3.6 µs at 4,000 (the counter queries, which build six kept panes).
  3. No per-evaluation fixed cost. An evaluation pays a floor regardless of rows: instantiating the compiled root, binding sources, encoding the answer. The counter queries' 14 ms per evaluation over 4,000 samples is mostly this floor. The schema prices per row or per state only.
  4. One update per sketch, whatever the materialization. The data plane pays very different costs for the same sketch update: KLL 170 ns/row in the query-time batch build against 28 ns/sample in the ingestion updater; DDSketch 208 against 173. Stage 3 must use one number for both, so it misprices either ingestion-time or query-time plans. The table here uses the query-time build cost.
  5. Matching needs the exact configuration. Entries match on SketchParams equality. Stage 3 sizes KLL's k from the accuracy target and the data (469 at 1,000 series, 506 at 5,000), so a deployment cannot know beforehand which k its candidates use. The sketch-bench table (k ∈ {25, …, 800}) priced none of them. Either a cost per parameter (e.g. update and merge as functions of k), or the installable plan listing the configurations its candidates use, would let a runtime measure what matters.
  6. Measuring some operations and not others biases selection. With KLL measured and exact operators on formulas, Stage 3 replaced both quantile queries' KLL by exact computation over raw samples (P141-m8 → P15-m8). That is not because exact was measured cheaper; its operators were never measured. That plan also fails the executor's validation (per-entity aggregate requires an explicit entity binding): Stage 3 selected a candidate the runtime cannot execute.
  7. No memory input. Measured costs are CPU only; a state's real footprint (a KLL allocates its full buffer when created) has no input, so predicted peak memory stays formula-based.
  8. The installable plan does not report the prediction. InstallablePlan.selection carries the price per second but not the predicted usage (CPU, memory), so the runtime calls e2e_plan a second time to compare prediction with measurement.

zzylol added 19 commits October 18, 2026 15:36
…ecution stack

The backend now executes ASAPPlanner's selected PhysicalASAPDAG as given
(#509): the Planner runs Stages 0-3, the backend supplies deployment inputs.

- Pin every Planner crate the backend uses to ASAPPlanner main (58999df2):
  asap-types, asap-frontend-promql, asap-logical-asap-optimization,
  asap-plan-selection, asap-executor. The sync script updates all five.
- control_plane: lower a PromQL PlanningWorkload, run Stages 1-3
  (run_stage1_to_stage3) under DeploymentInputs (DeploymentCapabilities and
  PlanSelectionSettings), and write the selected candidate's DAG, with each
  PaneChain expanded into its panes, plus the query each root answers
  (plan.json).
- data_plane: load and validate plan.json, accept Remote Write, forward
  queries to Prometheus until the query engine lands.
- Remove the backend's own plan selection, placement pricing, ERP, SDS,
  precompute and query engines, ClickHouse, OTel and OpAMP paths, which were
  built on the removed Planner APIs.
…puts

Each MatAtIngestion output the query side reads (the executor's
materialization frontier) reads one tumbling pane of raw samples. Outputs
that differ only in which pane they read share one SummaryDefinition,
identified by a SHA-256 of their sub-DAG with node ids blanked and offset
TimeShifts skipped. Each output binds to its definition with an offset: at
evaluation time T it reads the pane ending at T - offset. SummaryStore keeps
built panes by definition and end. A plan with a node kept across
evaluations is rejected: the backend declares it cannot keep one.

#509 Example 4b on a deployment without raw data: its six 10-minute KLL
panes become one definition with offsets 0..50 min.
An engine thread owns the installed plan (Planner IR holds Rcs) and receives
Remote Write samples from the HTTP server. For each stored summary
definition it buffers its Scan metric's samples by tumbling pane
(end - width, end], like PromQL ranges. When the watermark (newest sample
minus --allowed-lateness-ms) passes a pane's end, it runs the plan's Scan
over them (applying label matchers) and the definition's build from its
TimeRange input, then stores the output. Panes without samples are stored
empty; the first pane seen is skipped because it may be partial; samples for
built panes are dropped and counted. A full engine queue answers Remote
Write with 503 so senders retry.
Each plan root is compiled once with two kinds of inputs: stored outputs,
bound at evaluation time T to their definition's pane ending at T - offset,
and query-time raw ranges (TimeRange <- TimeShift* <- Scan), bound to the
metric's samples in (T - offset - range, T - offset] fetched from
Prometheus and filtered by the plan's Scan. An instant query whose text is a
planned query is answered with a Prometheus vector and x-asap-execution:
plan. Unplanned queries are forwarded; planned ones that cannot be answered
(pane missing or off the pane grid, fetch or execution failure) are
forwarded with x-asap-forward-reason.
For Examples 1, 3a, 3b, 4a and 4b: plan the workload, start the data_plane
binary against a Prometheus holding generated samples, Remote Write the
same samples, and require every planned query to be answered by the plan
with the Planner executor's result for the selected logical candidate with
nothing materialized (control_plane::plan_selected_at_query_time). 3b also
runs, and 4b only runs, on a deployment without raw data, so their panes are
built from Remote Write. Exact results must match exactly; Example 3a/4a's
five-year KLL windows exceed k and KLL compaction is randomized, so they
must agree within twice the 0.005 rank target.
A plan node materialized `MatAtQueryKeptAcrossEvaluations` (#509 Example 4's
B3) is now executed instead of rejected. Its output is kept in memory by
(definition, pane end), with the SDS identity rule, built from its pane's raw
samples the first time an evaluation reads it, reused by later evaluations,
and evicted like stored panes (older than the largest read offset plus two
pane widths, measured from the newest evaluation).

The control plane's default DeploymentCapabilities now set
query_time_results_retained. examples/planner-layering-example4-b3.json is a
workload for which the Planner selects B3 under the default cost model.
A pane computed at query time is kept across evaluations only once its end
is at or before the precompute engine's watermark (newest sample minus the
allowed lateness). A pane read while still open is computed for that
evaluation but not kept, so a later evaluation computes it again, including
samples that arrived late, and keeps it once it is closed.
A planned /api/v1/query_range runs one evaluation per step, start + k*step
through end, and returns a Prometheus matrix. Each query-time raw range is
fetched from Prometheus once for all steps and sliced per step; each kept
definition's missing panes are fetched once, so a kept pane built at one
step is reused by the later ones. Stored panes are read per step. If any
step cannot be answered, the whole request is forwarded with the reason.

start/end accept Unix seconds or RFC 3339 (now also for instant queries),
step float seconds or a Prometheus duration; requests Prometheus would
reject, and requests with a nonzero limit, are forwarded unchanged.
…and asap-executor only

Re-pin ASAPPlanner to main f0dc1825 and use its handoff API.

- The control plane calls `asap_planner::plan_installable` once and writes
  the returned `InstallablePlan` as plan.json. The backend's own pane-chain
  expansion, Stage 2/3 plumbing and query-time reference rebuild are gone;
  tests use `InstallablePlan::query_time_reference`.
- The data plane reads `stored_outputs` (built_at, pane window,
  retention_ms) and `query_time_raw_windows`, and selects every pane's and
  raw window's rows with `asap_executor::RawSampleSelector`. It no longer
  uses `frontier_from_timing` or `promql_rows`.
- The SDS keeps its own identity (sub-DAG hash, offset-only TimeShifts
  transparent) and keeps each definition for the largest `retention_ms`
  among its outputs, measured from the newest evaluation that can read it,
  instead of "largest offset plus two pane widths".
- Among Planner crates the workspace depends on `asap-planner` and
  `asap-executor` only; IR types come through `asap_executor::planner`.
- `--plan-selection-settings` on the control plane and
  `--execution-memory-limit-mib` on the data plane, which the benefit
  harness passes.
…a shared pane store

- Ingestion: N workers (`--ingestion-workers`, default one per core).
  Remote Write series are sharded by a hash of their label set; each worker
  owns its shard's open panes. The global watermark is the minimum over the
  workers that have seen a sample; when it passes a pane, every worker
  builds its part, and the last part completes the pane: parts are joined
  and the states of a group several workers saw are merged with the
  family's merge. A request is queued to every worker or refused whole.
- Queries: a pool of `--query-threads` threads, one query (and every step
  of a range query) per thread. The executor's compiled plans hold `Rc`s,
  so each thread reads plan.json and compiles its own.
- SDS: `RwLock<HashMap<(definition, pane end), Arc<pane>>>`. Panes are
  inserted once and never rewritten; readers clone the `Arc`. Kept
  query-time results use the same store, shared by the query threads.
  A stored pane binds to a root as a shared source, copied once per run
  (the executor's sources emit owned batches) instead of twice.
- jemalloc is the data plane's global allocator.
…he watermark it announces

Two races of the sharded engine, both found by a test that writes a pane's
samples and queries it at once:

- A planned query ran on the query pool while the ingestion workers could
  still be ingesting the Remote Write requests answered before it, so the
  newest pane was missing and the query was forwarded. A query now waits
  until every worker has run what was queued before it (`sync`), as the
  single engine thread's queue ordered it.
- A worker closed its panes at the watermark it read before another worker
  moved it, then announced the newer watermark to the other workers but
  not to itself, so its part of the newest pane never arrived. It now
  closes and announces the same watermark.
The ingestion side of a stored definition, Scan -> TimeShift* ->
TimeRange(pane) -> SummaryAgg, runs per sample instead of over each pane's
buffered rows:

- per series, once: the executor's RawSampleSelector turns the series'
  first sample into its input row (applying the Scan's matchers), and the
  row's group columns give its group; cached by label set;
- per sample: the open pane's state for that group is updated in place
  with the updater the executor's batch build uses for the family
  (create_planner_accumulator), with the same value and timestamp;
- per group, once: the definition's compiled build runs over the group's
  first row, and its output row is the template each closed pane's row is
  made from (pane timestamp and state replaced).

A build of another shape (row filter, keyed or shared-grid family,
operators between the range and the summary, matchers reading the value or
time) keeps the batch build. A parity test checks every family the
examples store (counter rate, sum, KLL at two pane widths) against the
executor's batch build of the same samples: exact states byte for byte,
KLL by quantile (KLL seeds its compaction randomly per sketch).
… and Prometheus remote read

- A local raw buffer keeps the recent samples Remote Write delivered,
  sharded like ingestion: each ingestion worker appends its shard. It keeps
  the plan's longest query-time raw read back from the newest sample: the
  largest `end_offset + width` of `query_time_raw_windows` and of the panes
  built at query time and kept (their retention, which covers range
  evaluations). `--raw-buffer-ms` overrides it; 0 keeps none.
- A raw window is read from the buffer; the part before the buffer's cut
  (its first sample, or the trimmed horizon of any shard) is read with
  Prometheus remote read (protobuf, `/api/v1/read`). The JSON query API is
  no longer used for raw samples. Forwarding of unplanned queries is
  unchanged.
- The data plane logs the buffer's series and samples every 30 s.
…est query-time read

The buffer kept every metric for the plan's single longest raw read: in the
benefit plan, 60 minutes of the counter whose raw reads span one minute,
and of metrics no raw window reads. It now keeps only the metrics the plan
reads at query time, each for its own longest read (query-time raw windows,
and panes built at query time and kept).
…ompile once; per-sample panes from columns

Re-pin ASAPPlanner to main 92bc1296: the executor runs every operator on
Arrow batches, compiled plans and RawSampleSelector are Send + Sync, and
StateColumnUpdater builds a state column sample by sample.

- Compile once: the plan's ingestion side (Ingestion) and its query roots
  (QueryEngine) are compiled when the plan is installed and shared by every
  ingestion worker and query thread through Arc.
- Raw samples to columns: a raw-sample window's input batch is built from
  the samples' times and values and each series' cached cells (series
  identity and label columns, dictionary-encoded), with the Scan's matchers
  applied once per series through RawSampleSelector. Query-time raw
  windows, kept panes and the batch-build fallback all use it.
- Ingestion per sample, no rows: a definition whose group is a series runs
  StateColumnUpdater::update in place per sample; at pane close each
  worker seals its states and builds its part with Batch::try_from_columns.
  Workers own disjoint series, so a pane is the workers' batches, shared by
  Arc in the SDS. Another shape keeps the plan's build, now run once over
  every worker's samples of the pane.
- Stored panes bind through Operator::source, which treats them as held
  outside the run.
- Stored panes are kept one pane width longer than the plan's retention:
  an evaluation at T runs once samples just after T have arrived, so the
  newest stored pane can be past T.
- Rows are read only to encode answers as Prometheus JSON.
…ture by id

Each label set is interned once, when Remote Write or remote read delivers
it: an ahash (fixed seeds) of its label bytes finds or creates one shared
`Series` with a dense `u32` id, the hash and the metric. Samples carry the
`Arc<Series>`; the router shards by the hash, and the per-series cache,
the per-sample group numbers, the raw buffer and the instant-selector
latest-sample map key by the id. The per-request label map build and the
per-sample SipHash and label-map comparisons are gone; text columns'
dictionaries use ahash.
…o the Planner

- `measure_costs` runs the executor's own summary operators as the data
  plane runs them, on one core: the `SummaryAgg` build per input row,
  `SummaryMerge` per input state and `SummaryEstimate` per state, for the
  sketch configurations of given plans and of a base table, and writes
  them as `MeasuredOperationCost` entries (base entries it does not
  measure are kept). Costs the table cannot hold go to `--report`: exact
  accumulators, the per-sample ingestion update, and each raw-reading
  query of a plan evaluated end to end per raw sample.
- `control_plane --prediction <file>` writes Stage 3's predicted CPU,
  memory and price per second for the selected plan, and how many of its
  nodes were priced from measured costs.
- `scripts/bench/cost_feedback.sh` measures, writes the settings with the
  measured table, and plans before and after with predictions.
@zzylol zzylol added enhancement New feature or request area: data plane Runtime ingestion, storage, and query-serving data paths integration: ASAPPlanner ASAPPlanner API, IR, candidate, and selected-plan integration labels Oct 20, 2026
@zzylol
zzylol force-pushed the feat/intern-series branch 3 times, most recently from 132d418 to 244e56d Compare October 21, 2026 00:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: data plane Runtime ingestion, storage, and query-serving data paths enhancement New feature or request integration: ASAPPlanner ASAPPlanner API, IR, candidate, and selected-plan integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant