Skip to content

Memo: convert memo-group candidates into the vector of all candidate DAGs #1002

Closed

Description

@zzylol

Problem, by example

One query, evaluated every minute, with max_error 0.01, failure_probability 0.001:

avg by (job) (quantile_over_time(0.5, data[5m]) / quantile_over_time(0.5, data[5m]) + quantile_over_time(0.9, data[5m]))

The planner keeps this workload's candidates as a memo: groups of logical sub-expressions, each with its implementation candidates (CandidateMemo). We measured it on main (0f7465c96) with a throwaway test that calls LogicalMemo::from_roots, try_populate_pass1 and the expanded_memo test helper:

After Memo groups Implementation candidates Full candidate DAGs
Stage 0 (LogicalMemo::from_roots) 8 – 1
Stage 1, Pass 1 (try_populate_pass1) 8 26 343 = 7 × 7 × 7
Pass 2 window composition + concrete state groups (expanded_memo) 378 2614 not known (a naive enumerator stopped at 1,000,000)

In Pass 1, the three quantile groups have 7 candidates each, and the other five groups have one each.

Nothing in the planner can turn the memo back into the representation used before the memo: the vector of every candidate DAG, one complete DAG per query set. So today:

  • the DAG viewer can't list the candidate DAGs the memo stands for, or draw any of them except the selected plan;
  • no test can check that the memo stands for the same candidate set the whole-workload path enumerated (for Pass 1 here, the same 343 DAGs);
  • for a small workload, nobody can price every candidate DAG and check that the MILP's selected plan is the cheapest.

What should exist:

let dags: Vec<Vec<QueryRoot>> = memo::enumerate(&memo, limit)   // every choice: one candidate per needed group
    .choices
    .iter()
    .map(|choice| memo::compose(&memo, choice))                  // one complete DAG per choice
    .collect::<Result<_, _>>()?;
assert_eq!(dags.len(), 343);                                     // Pass 1 memo of the example

Why it isn't done today

  • Composition exists only in two non-reusable places.
    • compose_memo_plan in crates/integration-tests/src/lib.rs is a test helper. It composes one choice (a group → candidate index map) by replacing each candidate's child-group nodes with their composed children, so each child group is built once and shared.
    • reconstruct_selected_candidates in crates/plan-selection/src/search/candidates/reconstruction.rs decodes the MILP's selection. It is pub(in crate::search) and works on the solver's candidate connections, not on a CandidateMemo choice.
  • Nothing enumerates choices. The count is not a product over groups: a group several candidates read must take one candidate per plan, a fused candidate implements several groups at once, and which groups are needed depends on the candidates chosen above them.
  • The whole-workload path did enumerate. That is stage1_candidates_of_choice in crates/logical-asap-optimization/src/workload_candidates.rs, which is being deleted under Reorganize by stage: one memo-based Pass 2, Stage 1 finishes the memo, deployment inputs split by stage, asap-planner as the only entry point #958. Once it is gone, nothing produces the "before" representation.

Proposal

The interfaces go in crates/logical-asap-optimization/src/memo/, next to CandidateMemo.

  • memo::compose(memo: &CandidateMemo, choice: &MemoChoice) -> Result<Vec<QueryRoot>, ComposeError>. It builds one complete DAG for every query from one choice. It is the production version of compose_memo_plan: the test helper is deleted and its callers call this instead. Plan reconstruction should share it or be checked equal to it.
  • MemoChoice. It maps each needed group to one candidate index. A fused candidate sets every group it implements.
  • memo::enumerate(memo: &CandidateMemo, limit: usize) -> MemoEnumeration { choices: Vec<MemoChoice>, complete: bool }.
    • It walks from the query roots and picks one candidate per needed group. A group's choice is shared by every candidate that reads it.
    • It skips choices that are inconsistent with an earlier fused candidate.
    • It stops at limit, with complete: false.
    • Choices that compose to structurally equal DAGs are counted once.
  • Validity stays where it is. enumerate produces every composable choice. Accuracy at the query roots, Stage 2 legality and Stage 3 cost stay with the existing validator and stages, as with the whole-workload candidates.

Open choice: should the enumeration also report an exact count past limit? A count needs its own walk (no DAGs built); an upper bound is the product of each group's candidate count. Proposed: report complete and the product as an upper bound in v1, and add an exact count only if the viewer needs it.

Scope and acceptance

In scope: compose, enumerate, MemoChoice, deleting compose_memo_plan, and a "memo to candidate DAGs" algorithm section in docs/design_docs/proposals/planner-layering.md.

Out of scope: drawing the memo and the enumerated DAGs in the DAG viewer (the viewer PR that uses this), and enumerating whole evaluation workloads (the limit binds there).

Acceptance tests:

  1. Example above, Pass 1 memo. enumerate returns 343 choices with complete: true. Their composed DAGs equal, as a set, the candidates stage1_candidates_of_choice builds for the same workload, on main before Reorganize by stage: one memo-based Pass 2, Stage 1 finishes the memo, deployment inputs split by stage, asap-planner as the only entry point #958 deletes it.
  2. planner-layering Examples 1–4. Same set equality at Pass 1. After Pass 2, every composed DAG passes the existing plan validator or is rejected with a reason.
  3. Selected plan. Composing the MILP's selected choice gives the selected plan's logical DAG.
  4. Small-workload optimality cross-check (devtools, optional). Price every enumerated DAG with Stage 2 and Stage 3. No valid one is cheaper than the MILP's selected plan.

Status and related work

Activity

  1. added
    enhancementNew feature or request
    dag-viewerDAG export and visualization tooling
    optimizerWorkload optimization and plan selection
    searchCandidate-plan search space and algorithms
    on Oct 28, 2026
  2. zzylol commented on Oct 28, 2026

    @zzylol
    ContributorAuthor

    Measured while implementing #1009: the Pass 1 memo is a superset of the whole-workload plans, not equal to them. For the example query it stands for 343 DAGs:

    • 27 are exactly the plans compose_logical_candidate builds; these are the choices whose candidates all have budget_share 1.
    • 316 are budget-share variants only the memo has: an operand's summary sized for a half or a third of the accuracy budget.

    Acceptance test 1 is therefore checked as that split. Acceptance 3 (composing the MILP's choice gives the selected plan) needs the selected choice in the document, so it moves to #1003.

  3. zzylol commented on Oct 28, 2026

    @zzylol
    ContributorAuthor

    Corrected 2026-10-28: production numbers from the stage-pipeline document; earlier numbers came from a test helper without the series-identity column.

    The example in this issue was measured with asap_integration_tests::fixtures::lower_promql / expanded_memo. That lowering omits the PromQL series-identity column, so the per-series quantile had no unique key and the two quantile_over_time(0.5, data[5m]) stayed two groups (G2, G3). The production frontend adds $promql_series_identity, which gives the per-series output a unique key (series identity, timestamp), so they share one group. Production numbers, from tools/dag-viewer/examples/memo-ratio-of-quantiles.json (main at d728d05c):

    Group Logical sub-expression Reads
    G0 Scan data –
    G1 TimeRange 5m G0
    G2 per-series quantile 0.5 (both occurrences) G1
    G3 G2 / G2 G2 (both operands)
    G4 per-series quantile 0.9 G1
    G5 G3 + G4 G3, G4
    G6 avg by (job) G5
    After Groups Implementation candidates Candidate DAGs
    Stage 0 7 – 1
    Stage 1, Pass 1 7 19 (G2 and G4 have 7 each) 49 = 7 × 7 (16 use only budget_share 1)
    Pass 2 window composition 7 259 (G2 and G4 have 127 each: 7 Pass 1, 120 Panes) > 1,000 (enumeration limit)
    Pass 2 rules + concrete state groups 354 (336 are PaneChain shared-build groups) 1738 (G2 and G4 have 691 each: 10 Pass 1, 336 Panes, 345 SharedBuild) > 1,000 (enumeration limit)

    For this issue: the memo_enumeration.rs checks (343 = 27 + 316, 378 groups, 2614 candidates) still run on the helper's lowering, where they hold. In production the Pass 1 memo stands for 49 DAGs, and 16 of them use only budget_share 1. Whether those 16 are exactly the whole-workload plans has not been checked on the production lowering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    dag-viewerDAG export and visualization toolingenhancementNew feature or requestoptimizerWorkload optimization and plan selectionsearchCandidate-plan search space and algorithms

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions