Skip to content

Memo: identical derived producers are duplicated, so a shared finalized result or top-k cannot be stored once #979

Open

Description

@zzylol

Problem, by example

Two exact PromQL queries over m: 1,000,000 series, one sample every 15 s, 100 jobs, 15 minutes of rows held. Both are evaluated every minute. The test is rollup in crates/integration-tests/tests/memo_covers_whole_workload_pass2.rs.

sum by (job) (m)             # q0, the coarse sum
sum by (job, instance) (m)   # q1, the finer sum q0 can be read from
Plan Cost/s
Whole-workload Pass 2, cheapest of 9 plans (fixture-2) 0.0126783
Memo search, default budget 0.0137854
Memo search, 300 s and 4096 evaluations (202 made) 0.0137854

Both plans come from the same logical rollup: q0 reads q1's per-(job, instance) sums. They differ in what can be stored:

  • The whole-workload winner finalizes q1's exact accumulator once (FinalizeExactAccumulator, node 3), stores those finalized rows, and has q0's rollup read them.
  • The memo's plan keeps the summary state (node 2) and builds two identical FinalizeExactAccumulator { child: 2 } nodes (3 and 6): one inside the rollup candidate and one inside q1's own candidate. So "store the finalized rows once, read them twice" cannot be expressed. The memo's cost equals the whole-workload plan with the same rollup but the other materialization (fixture-3, 0.0137854).

A longer search does not help; the memo cannot express the cheaper plan.

The same pattern shows up in exact top-k reuse (topk_reuse in the same file):

topk by (job) (5, rate(m[1m]))
topk by (job) (10, rate(m[1m]))
Plan Cost/s
Whole-workload Pass 2 (fixture-31): stores Limit(10) ▸ Sort, read by both queries 0.7756417
Memo search, default budget; also 300 s / 4096 evaluations (35 made) 0.7800940

The memo's plan stores the full Sort and builds two identical Limit(10) nodes over it (5 and 8), plus a redundant Limit(10) over Limit(10). The shared top-10 producer is never one node.

Why it happens

  • A memo rule that reads another group's result puts the derived node (the finalize, the Limit(k_max)) among its candidate's private nodes, under the shared child group.
  • The reader's own candidate builds an equal node privately too.
  • Private nodes are not hash-consed across candidates, so Stage 2 sees two producers and can store neither one for both readers.
  • The whole-workload path rewrites one whole DAG, where identical nodes are interned once, so it can.

Proposal

Intern derived producers as memo groups, so that equal derived nodes over the same child group are one group with several readers. Two places to do it:

  • Where a rule creates the node: append_producer already does this for the top-k ranking's Sort and Limit(k_max). Check why the plan still duplicates them, and extend the same treatment to the rollup's finalize.
  • After all rules run: hash-cons the candidates' private nodes, turning equal private subtrees over equal child groups into shared groups. Do this before Stage 2, so materialization can choose to store such a group once.

Then Stage 2's existing "two or more readers → MatAtQuery/stored" choice applies to it.

Scope and acceptance

Related

Activity

  1. added
    bugSomething isn't working
    optimizerWorkload optimization and plan selection
    on Oct 27, 2026
  2. zzylol commented on Oct 28, 2026

    @zzylol
    ContributorAuthor

    Measured while fixing this; the two halves have different causes. Both come from instrumented runs of memo_covers_whole_workload_pass2.rs that dumped each evaluated plan's DAG.

    Rollup: fixed in the linked PR.

    • The rule already interned the finalize as one producer group, so there was only one such node in the memo. The gap came from two other things:
      • the finer query could read that group only through an identity Project, because a bare alias of another group is rejected by the inventory. That projection costs 1111 CPU ops/s, exactly the 0.0011/s gap;
      • the alternative computed the finalize twice.
    • The rollup now reads the finer query's own result group when that result is the finalized value and every implementation of it is exact. The memo selects the whole-workload plan node for node: 0.0126783/s on both sides.

    Top-k: not a duplicated producer. It is a search miss (#963).

    • The memo's plan already stores one Limit(10) ▸ Sort, and each reader derives Limit(k) ▸ Project from it. That is fewer nodes than the whole-workload winner, which also re-sorts per reader.
    • The search evaluated that shape in two forms:
      • over a query-time pane chain: 0.7800940/s or 0.8203239/s, depending on the run;
      • over a time range with the summary build stored instead: 1.0255661/s.
    • It never evaluated a time range with the Limit(10) stored, the winner's 0.7756417/s. Every lower bound is 0.
    • The test stays ignored, now pointing to Search: certify lower bounds for query-time scans and builds #963.

    So once the PR merges, nothing in this issue blocks #958 step 4. The remaining top-k gap belongs to #963.

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

    CSEbugSomething isn't workingoptimizerWorkload optimization and plan selection

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions