Skip to content

DDIR: the optimizer as a DDIR program (an e-graph over each scope) - #856

Open
frankmcsherry wants to merge 12 commits into
master-nextfrom
ddir-egraph
Open

DDIR: the optimizer as a DDIR program (an e-graph over each scope)#856
frankmcsherry wants to merge 12 commits into
master-nextfrom
ddir-egraph

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

Stacked on #855 (the first four commits here are that PR; merge it first and this diff shrinks to the e-graph).

What. Program::optimize now runs an equality-saturation optimizer written in DDIR (interactive/src/egraph/optimize.ddp) over each scope of a program, innermost first, and rebuilds the scope from the extraction. Scope::optimize, the hand-written pass, remains: the e-graph program is optimized by it (it cannot optimize itself), and the tests compare against it.

The e-graph. Each scope is reified at the grain of one node per linear step, with everything an operator closes over (a projection, a predicate, a reducer, a child scope's body) interned to an opaque id, and each node priced by the host. The program computes congruence closure (a concatenation's children are the unordered multiset of its leaves through nested concatenations, so commutativity and associativity are free), arrange idempotence, and a tree-cost extraction. The host instantiates the rules that need a fact about a scalar operator and hands them to the program as asserted equalities: demand pushdown (a reducer or join that reads only some of an input's fields equals itself over that input narrowed to them, below the input's arrange), join commutativity, and filter pushdown (a filter equals itself composed through the projection, negation, concatenation, or join below it). Rules apply to the nodes they mint too, to a fixpoint. egraph/scalar.rs is the whole interface to the scalar language: widths after an operator, which fields a term reads, rewriting a term to a narrowed or renamed row, composing a predicate through a projection.

Extraction and cost. The cost model lives entirely in extraction; saturation never sees a price. A node costs weight × (1 + row width) × a relative row volume (inputs carry a base volume, a filter halves it: an estimate there to order alternatives, nothing more). The program's tree-cost optimum is refined by the host against the DAG cost, what the choices materialize from the roots with sharing counted once, by hill-climbing (optimal DAG extraction is NP-hard). Extraction::Random walks random acyclic choices instead, and the test shows every corpus and explanation program evaluates identically under it.

Results. On the eight corpus programs the e-graph matches the hand-written optimizer's operator count exactly; where the new rules fire it costs less (adt, tour). On the explanation rewrites it finds three more merges than the hand-written pass on each (reach 168 -> 143 by hand, 140 here; scc 647 -> 519 vs 516), purely from phase ordering. Outputs are identical before and after everywhere. The whole interactive suite runs through the e-graph. The pass takes 20-35 ms in release on the explanation programs (about 40x the hand-written pass; roughly a third is Debug-string interning in the host and half is one DD evaluation per scope, both addressable).

Also here: the arithmetic e-graph program (egraph_math.ddp) with its Rust reference oracle, the exploration this grew from, and the ddir driver passing its inputs' widths so the narrowing rules can fire.

🤖 Generated with Claude Code

https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK

frankmcsherry and others added 5 commits September 2, 2026 05:43
Both the row and the corgi `leave_dynamic` read `cap.time()`, which now
panics ("expected a singleton stamp") when a message carries timestamps
from two epochs — late iterations of one alongside early ones of the next,
which any steady-state run of an iterative program produces. Hold a
capability per stamp element, each truncated exactly as the records are,
and open the session on the set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
The round loop churned synthetic rows from `gen_row`, which with a file-fed
input meant retracting rows the dataflow never saw and inserting random
ones (duplicate ids, cycles). Each update now retracts one of the file's
rows and re-inserts it with its last field rewired below its first, so a
round is an edit to data the dataflow holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
compare_idx and gather read sum discriminants in place (WIP master
3c4b0d6f), which is what took the e-graph's 200k-node saturation from 43s
to 23s on the corgi backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
…a program

An iteration quiesces when its feedback carries no records, so a var whose
step's additions and retractions cancel only logically — a `negate` under
a `concat` — never quiesced: the vec backend ran forever on a program
whose fixpoint was reached in one round. Every var is now set to its
consolidated step (`Backend::consolidate`, through the arrangement by
default). The suite's timings are unchanged; the programs there all fed
their vars through a reduce, which consolidated by nature.

tests/identifiers.rs writes DD's `identifiers` algorithm in DDIR: each
record proposes hash(round, record); among the proposers of an id the
least (round, record) wins and the losers try the next round; with hash,
min, negate and a var, over 64 slots for 32 records, every record gets
its own id (25 settle at round 0, the rest by round 4). It is the
primitive an e-graph rule needs to mint a node for a new term inside the
program.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
…messages come from

Instrumenting the batch-shipping operators on the run that panicked (a
DDIR program at 20k nodes, four workers, epochs fed without waiting) shows
the multi-element stamps being minted: an arrange retiring (1, [18]) and
(2, []) — epoch 1 at round 18 alongside epoch 2 just entered — in one
batch, then a join forwarding its input batch's set, then a reduce
retiring the same pair, and finally an arrange whose batch, forwarded by
as_collection under the retiring set and delayed element-wise by the
feedback, reached the scope's exit as (1, [19]), (2, [1]).

Which retirements coincide depends on scheduling, so the test makes the
message directly: an operator that ships its input under a capability for
epoch e round 3 and one for epoch e + 1 round 0, into leave_dynamic. It
panics with "expected a singleton stamp" before this branch's fix and
passes with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
frankmcsherry and others added 7 commits September 2, 2026 08:16
…ount, threshold route by time

A batch is shipped under the set of capabilities it retires, so a message
of batches carries several timestamps when two epochs retire together.
The operators that turn batches back into records forwarded the records
under that same set, which is how a multi-element stamp reached
`leave_dynamic` and anything else that reads a message's time as one.

`StampRouter` (collection.rs) gives one capability per element of a
message's stamp and routes each record to the first element at or before
its time (a batch's times are at or beyond one of the capabilities it
retired under, so every record has one). It is applied at every seam
that makes records from batches: the vec `as_collection`
(`flat_map_batches`), `count`, `threshold`, the columnar
`as_recorded_updates`, and DDIR's corgi `as_collection` (a chunk under one
capability goes whole, as before; under several it is split by row). With
that, a collection's messages carry one timestamp each; only streams of
batches carry sets.

Not routed: `join_with_tactic`'s output, whose tactic builds containers the
operator cannot split by time (the fix belongs in the tactics, a builder
per element of the fresh batch's set), and `as_container`, which forwards
opaque containers. `leave_dynamic` keeps holding a capability per element,
so it is safe either way.

tests/dynamic.rs: a hand-built batch under capabilities for epoch 0 round
3 and epoch 1 round 0, through `as_collection` into an operator that reads
each message's time as one, lands its records in their two epochs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
tests/programs/egraph_math.ddp: equality saturation over a term DAG with
declared terms (Num/Var/Add/Mul/Neg), the congruence-closure core of
arrange_idem, and node-CREATING rules: commutativity mints the mirror term,
constant folding mints the value, plus x+0, x*1, x*0, and double negation.
Identity is exact — signatures and terms are keys — and a minted node's
name is the hash of its canonical form, with a `collisions` export that
must stay empty. Extraction is a cost fixpoint (cheapest term per class)
and `best` names the node achieving it.

tests/egraph.rs runs the program against a small Rust equality-saturation
engine with the same rules on six random DAGs: the partition of the input
nodes, every class cost, and the absence of collisions all agree. The
program also joins the backend gate (vec vs corgi at 1-4 workers and over
serializing channels).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
interactive/src/egraph: reify a scope into a term table (one node per linear
step; projections, predicates, reducers, labels, and child-scope bodies
interned to opaque ids; child scopes optimized first and entered as opaque
leaves), run `optimize.ddp` — congruence closure with unordered Concat
children plus arrange idempotence, and a cost-fixpoint extraction — on the
vec backend, and rebuild the scope from the chosen node per class, folding
single-consumer chains of steps back into Linear operators.

tests/egraph_opt.rs: on every corpus program the result evaluates like the
original and matches Scope::optimize's operator count exactly (reach 11,
scc 32, tour 31, ...); on the explanation rewrites it does better: reach
168 -> 143 by hand, 140 by e-graph; scc 647 -> 519 by hand, 516 by
e-graph, with identical outputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
… facts

The optimizer prices each node by an op weight times one plus a row width:
the width of the rows an Arrange or a Reduce holds (its input's), the width
of the rows anything else produces. Widths come from a forward pass over the
scope given the root inputs' widths (`optimize(program, &source_widths)`),
through projections, reducers, joins, feedback vars, and child scopes.

Rules now come in two kinds. `optimize.ddp` states arrange idempotence over
the term table alone. The host instantiates the rules that need a fact
about a scalar operator, and hands them to the program as asserted
equalities (input 2):

  * demand pushdown — a reducer that reads no values (count, distinct)
    equals itself over a key-only projection of its input; a join that
    reads only some fields of an input's values equals itself over that
    input narrowed to those fields (below the input's arrange), reading
    them at their new positions;
  * join commutativity — a join equals the join of its inputs swapped,
    reading them swapped; congruence then merges the two spellings.

`egraph::scalar` is the interface those rules need of the scalar language,
answered for DDIR's terms: widths after an operator, which fields of a row
a term reads (`Demand`), rewriting a term to read a narrowed row or renamed
rows, and the narrowing step itself. It is meant to grow by the same kind
of question — fuse two operators, extract a common subexpression, report
the equalities an operator implies — rather than by any syntax.

tests/egraph_opt.rs now compares costs under the model instead of op
counts (a narrowing is one more op and cheaper): corpus and explain
programs still evaluate identically; the e-graph matches or beats the
hand-written optimizer everywhere (adt 30 -> 28, tour 229 -> 223 from the
count/distinct narrowings; explain(reach) 553 -> 548, explain(scc) 1543 ->
1538); and three focused tests show the narrowing before a count, the
narrowing of a join input below its arrange, and two joins written both
ways around merging into one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
…h it

Tree extraction (the cost fixpoint in optimize.ddp) counts a shared subterm
once per use, so it will narrow a join input that another join needs whole
and pay for a second arrangement of it. Optimal DAG extraction is NP-hard;
the host now hill-climbs from the tree optimum on the weight of what the
choices materialize from the roots (each reached class once): a move
changes one reached class's node and is taken when the whole DAG gets
strictly lighter, which also rules out cyclic choices. A test shows the
shared arrangement kept.

`Program::optimize` now runs the e-graph, with the root inputs' widths
unknown (rows priced by operator alone, so nothing narrows); the new
`optimize_with_widths` takes them, and the ddir driver passes its inputs'
arities. `Scope::optimize` remains as the hand-written optimizer, which the
e-graph program itself is optimized by (it cannot optimize itself), and
which the tests compare against. The whole interactive suite runs through
the e-graph unchanged. On the explanation rewrites it takes 20-35 ms in
release (170-370 ms in debug) against under a millisecond hand-written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
…to a fixpoint

Concatenation is associative: a Concat's signature now lists the classes of
its leaves through nested Concats (a static closure in optimize.ddp), so
(a + b) + c and a + (b + c) are one class, and the rebuild splices a
single-consumer nested Concat into its parent, the way it folds a chain of
steps into one Linear.

Filter pushdown, host-instantiated from scalar facts: a filter equals the
projection below it over the filter composed through that projection
(`scalar::compose`: every $0[i]/$1[j] replaced by the field's term, through
spreads when the widths are known); the same filter below a negation or an
entry; the concatenation of the filter over each part; and, over a join,
the join with the filter composed through its projection on whichever
input alone the composed predicate reads, below that input's arrange.

For any of that to be chosen the price has to see rows, not operators:
each node is now priced by the host as weight x (1 + width) x a relative
volume, where inputs carry a base volume and a filter halves what it sees
(an estimate, there to order the alternatives a rule proposes). The
program takes the price as given.

Rules now apply to the nodes they mint too, to a fixpoint (a minted node
is structurally below what it matched), with nodes interned by signature
so re-minting an existing node yields it; a filter therefore sinks as far
as its predicate can be expressed.

Tests: nested concatenations however associated become one three-part
Concat; a filter after a join sinks to the input it reads, below the
arrange and through the projection that built the input; a filter after a
concatenation sinks into each part. The corpus and explain programs still
evaluate identically and cost no more than the hand-written optimizer's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
…extraction's alone

`Extraction::Random(seed)` walks from the tree optimum through a few
hundred random moves, each kept if the choices stay acyclic, whatever it
costs. The test runs it over every corpus program and both explanation
rewrites, four seeds each: every program evaluates identically, through
alternatives the cost never picks (swapped joins, pushed filters,
narrowed inputs). Saturation and the rules are the same whatever is
chosen; the price is a preference, not a premise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k2GSwxmvD2LvckkoXi6GK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant