Skip to content

Commit 2cab899

Browse files
oflattclaude
andcommitted
flowlog: native :merge for term-mode congruence (--native-merge)
Port the non-proof half of egglog PR egraphs-good#933 to the FlowLog backend: replace the rule-encoded @congruence_rule* with an FD view keyed on children + a UnionId-style side-effecting merge that unions colliding outputs at FD-conflict time (drained at the iteration boundary). Behind a --native-merge flag (FlowLog-only, term mode, implies --native-uf); off by default with byte-identical rule encoding. Bit-exact to flowlog's rule encoding; agent-measured ~1.16x (1.28x with --fast-rebuild) faster than --native-uf on math-microbenchmark. Also fixes a pre-existing clone_boxed panic on --native-uf --flowlog/feldera. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82f2d7b commit 2cab899

8 files changed

Lines changed: 410 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## [Unreleased] - ReleaseDate
44

5+
- **`--native-merge`: inline congruence via a native backend merge (FlowLog, term mode).** Ports the non-proof half of egraphs-good/egglog PR #933 to the FlowLog dataflow backend: instead of desugaring congruence into a seminaive `@congruence_rule*` self-join run inside the per-`(run)` rebuild fixpoint, a CONSTRUCTOR's `@<F>View` is keyed on its CHILDREN ONLY (an FD view `(children) -> eclass`) with a side-effecting `MergeFn::UnionId` merge that, on an FD conflict (same children, two eclasses), UNIONS the two eclasses into the union-find and keeps the min — congruence done INLINE at insert time, and the `@congruence_rule*` rule dropped entirely. Implies `--native-uf` (the injected union edge goes into FlowLog's in-core union-find via `enqueue_union`, drained at the iteration boundary like every other union; the displaced-id signal drives the existing rebuild to re-canonicalize children, which re-triggers the merge for transitive congruence). The frontend records each FD view's name (`EncodingState::native_merge_views`) so lowering substitutes `MergeFn::UnionId` and `register_native_merge_view` tells the backend which UF owns the eclass column; the view body/set/delete/subsume forms switch to the `(= eclass (@view children))` shape via the new `view_body_atom`/`view_delete` helpers. Bit-exact with the bridge `--term-encoding` oracle (math-microbenchmark runs 1/3/5/7, hand-written congruence tests, a Herbie sample file) and faster than the rule-encoded congruence (math run 9: 1.14x vs `--native-uf`, 1.21x vs `--native-uf --fast-rebuild`). FlowLog-only, term mode, eq-sort-output constructors with ≥1 input; 0-input constructors (globals) keep the baseline all-columns view. Off by default — the rule-encoded congruence is byte-identical when off. Also fixes a pre-existing `--native-uf --flowlog`/`--feldera` panic (`clone_boxed` unimplemented) by skipping the cli `with_term_encoding_enabled` clone for those backends (they provision the typechecker at construction). (`src/proofs/proof_encoding.rs`, `src/lib.rs`, `src/cli.rs`, `egglog-bridge-flowlog/src/lib.rs`, `egglog-backend-trait/src/lib.rs`, `egglog-experimental/src/main.rs`.)
6+
57
- **Proof mode now supports canonicalize-at-creation, and `--fast-rebuild` works with `--proofs` (native bridge).** Previously canon-at-creation and the `--fast-rebuild` `δview ⋈ uf_old` drop were gated off in proof mode, because the proof-mode flat UF index `@UF_Sf` is `(S) -> Pair(S, Proof)` (key/output types differ, so `DefaultVal::Identity` is unavailable and a lookup of a just-minted key fails). Proof-mode canon now goes through two per-eq-sort `ReadPrim`s declared via a new `:internal-uf-pair-prims` annotation on the `@UFPair_S` sort (so they survive the desugar round-trip): `@uf_find_leader_S` (`(S) -> S`, lookup-or-self leader for child columns) and `@uf_find_or_refl_S` (`(S Proof) -> @UFPair_S`, lookup-or-`(pair x fallback)` for the representative column, no row inserted — bit-exact tuple counts). The constructor term is still built from the original (non-canonical) args so the rule proof's claimed equality matches; the view's child/representative columns are canonicalized and the per-child `child = leader` proofs are threaded into the view proof via `Congr`/`Trans(Sym(rep_pf), …)`. Bridge-only (gated on `Backend::supports_inline_table_lookups`); constructors only (custom `:merge` function views keep the baseline rebuild-rule canonicalization and their `δview` term under fast-rebuild). Bit-exact with proof-mode full-rebuild, and proofs still check. (`src/proofs/proof_encoding.rs`, `src/typechecking.rs`.)
68

79
- **Fixed: `egglog-experimental` ignored `--threads`/`-j` and silently ran rayon at full width.** `egglog::cli` configures the rayon global pool via `set_num_threads` → `build_global`, but only after it's handed an already-constructed egraph; the experimental wrapper main builds the (experimental/backend) egraph first, and that construction touches rayon — lazily initializing the global pool at its default (all logical CPUs). So `cli`'s later `build_global(1)` failed (`GlobalPoolAlreadyInitialized`) and `--threads 1` was a no-op (e.g. ~257 threads spawned instead of running serial). Fix: the experimental main now calls `set_num_threads` (parsing `--threads`/`-j`, default 1) BEFORE constructing the egraph, and `EGraph::set_num_threads` is now idempotent (a `Once` guard — first call wins, later calls silently ignored per its doc, no spurious warning). The plain `egglog` binary was unaffected (it constructs `EGraph::default()`, which doesn't touch rayon, so its `set_num_threads` already won). (egglog-experimental + `EGraph::set_num_threads`.)

egglog-backend-trait/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,16 @@ pub trait Backend: Send + Sync {
295295
/// rebuild; the dataflow/SQL backends drive their own host-pass rebuild.
296296
fn register_nativerb_view(&mut self, _uf_func: FunctionId, _view_func: FunctionId) {}
297297

298+
/// `--native-merge` (term-encoding native-UF, non-proof): associate an
299+
/// FD-keyed constructor view (`view_func`, keyed `(children) -> eclass`) with
300+
/// the union-find (`uf_func`) that owns its eclass (OUTPUT) column. The view's
301+
/// `MergeFn::UnionId` merge then injects a union into this UF on an FD conflict
302+
/// (same children, two eclasses) — congruence done INLINE at insert time
303+
/// instead of by a self-join rule. The frontend calls this before each run for
304+
/// each not-yet-registered native-merge view. Default no-op: only backends
305+
/// that inject the union on FD-conflict (currently FlowLog) implement it.
306+
fn register_native_merge_view(&mut self, _uf_func: FunctionId, _view_func: FunctionId) {}
307+
298308
/// Register a union-find-backed function (see upstream PR #782).
299309
///
300310
/// The function has schema `(S) S` for an EqSort `S` and records leader

egglog-bridge-flowlog/src/lib.rs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ pub struct EGraph {
304304
/// non-triangle rules are unaffected. Off by default — when off the FlowLog
305305
/// join path is byte-identical to the pre-WCOJ behavior.
306306
pub(crate) wcoj_enabled: bool,
307+
/// `--native-merge`: maps an FD-keyed CONSTRUCTOR view's [`FunctionId`] to the
308+
/// [`FunctionId`] of the union-find (`@UF_Sf`) that owns its eclass (OUTPUT)
309+
/// column. Populated by [`Backend::register_native_merge_view`]. When a view
310+
/// in this map has an FD conflict (`resolve_merge` finds two eclasses for one
311+
/// children key), the loser is `enqueue_union`-ed with the winner into this UF
312+
/// — congruence done INLINE at insert time instead of by a self-join rule.
313+
pub(crate) native_merge_uf: HashMap<FunctionId, FunctionId>,
307314
}
308315

309316
impl Default for EGraph {
@@ -377,6 +384,7 @@ impl EGraph {
377384
native_uf_disp_rel: HashMap::new(),
378385
native_uf_rebuild_dd_ir: HashMap::new(),
379386
wcoj_enabled: false,
387+
native_merge_uf: HashMap::new(),
380388
}
381389
}
382390

@@ -658,6 +666,14 @@ impl EGraph {
658666
by_key.entry(&row[..inputs_len]).or_default().push(row);
659667
}
660668
}
669+
// `--native-merge`: if this function is an FD-keyed constructor view, an
670+
// FD conflict (same children, two eclasses) means those eclasses are
671+
// CONGRUENT and must be unioned. The merge keeps the min output (the UF
672+
// leader, matching union-by-min) AND injects a union of every losing
673+
// eclass with the winner into the view's union-find — congruence INLINE at
674+
// insert time. Collect the (a, b) pairs to enqueue after the borrow ends.
675+
let native_merge_uf = self.native_merge_uf.get(&f).copied();
676+
let mut union_pairs: Vec<(u32, u32)> = Vec::new();
661677
// Resolve each touched key; collect the rows to remove and the winner to
662678
// insert. Only keys with >1 candidate row can change.
663679
let mut new_rows: Vec<Row> = Vec::new();
@@ -682,14 +698,26 @@ impl EGraph {
682698
let mut winner: Vec<u32> = cands[0][..inputs_len].to_vec();
683699
winner.push(chosen);
684700
let winner: Row = winner.into_boxed_slice();
685-
for row in cands {
686-
if **row != *winner {
687-
drop_rows.insert((*row).clone());
701+
for row in &cands {
702+
if ***row != *winner {
703+
drop_rows.insert((**row).clone());
704+
}
705+
}
706+
// Native-merge: union each candidate eclass with the chosen winner.
707+
// `enqueue_union` is idempotent for already-equal ids and picks the min
708+
// leader itself, so passing every candidate (incl. the winner) is safe;
709+
// the dedup against `chosen` keeps the staged list small.
710+
if native_merge_uf.is_some() {
711+
for row in &cands {
712+
let out = row_col(row, inputs_len);
713+
if out != chosen {
714+
union_pairs.push((chosen, out));
715+
}
688716
}
689717
}
690718
new_rows.push(winner);
691719
}
692-
if drop_rows.is_empty() {
720+
if drop_rows.is_empty() && union_pairs.is_empty() {
693721
return false;
694722
}
695723
{
@@ -712,6 +740,22 @@ impl EGraph {
712740
self.index_insert_row(f, r);
713741
}
714742
}
743+
// `--native-merge`: inject the congruence unions into the view's UF. The
744+
// ids are drained + applied at the iteration boundary (`native_uf_drain_all`,
745+
// like every other union); the displaced-id signal then drives the rebuild
746+
// that re-canonicalizes any other rows holding the loser eclass. Returning
747+
// `true` here keeps the outer saturate loop iterating until the unions and
748+
// their rebuild fallout reach a fixpoint.
749+
if let Some(uf_func) = native_merge_uf {
750+
if let Some(uf) = self.native_ufs.get_mut(&uf_func) {
751+
let mem = self.native_uf_members.entry(uf_func).or_default();
752+
for (a, b) in &union_pairs {
753+
uf.enqueue_union(*a as i64, *b as i64);
754+
mem.insert(*a);
755+
mem.insert(*b);
756+
}
757+
}
758+
}
715759
true
716760
}
717761
}
@@ -859,6 +903,17 @@ impl Backend for EGraph {
859903
Ok((id, canon))
860904
}
861905

906+
fn register_native_merge_view(&mut self, uf_func: FunctionId, view_func: FunctionId) {
907+
// `--native-merge`: the view's eclass (OUTPUT) column belongs to `uf_func`.
908+
// Record the association so `resolve_merge` injects a union into this UF on
909+
// an FD conflict (same children, two eclasses) — inline congruence.
910+
debug_assert!(
911+
self.native_ufs.contains_key(&uf_func),
912+
"register_native_merge_view: uf_func is not a native union-find"
913+
);
914+
self.native_merge_uf.insert(view_func, uf_func);
915+
}
916+
862917
fn table_size(&self, table: FunctionId) -> usize {
863918
self.mirror.get(&table).map(|s| s.len()).unwrap_or(0)
864919
}

egglog-experimental/src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ fn main() {
2323
// `--native-uf` (PR #782) so the duckdb egraph is built in native-UF mode
2424
// when either is set — otherwise the `--native-uf` encoding would emit
2525
// UF-backed functions against a relational duckdb backend.
26+
// `--native-merge` does congruence inline via the backend's UnionId merge and
27+
// injects the union edge into the in-core union-find, so it REQUIRES native-UF
28+
// — provision the backend in native-UF mode when it is set (mirrors cli.rs's
29+
// `args.native_uf = true` fold for `--native-merge`).
2630
let want_native_uf = argv
2731
.iter()
28-
.any(|a| a == "--duck-native-uf" || a == "--native-uf");
32+
.any(|a| a == "--duck-native-uf" || a == "--native-uf" || a == "--native-merge");
2933
// `--fast-rebuild` engages the duckdb backend's delta-scoped rebuild; the
3034
// pre-built duckdb egraph must carry it so the flag survives cli.rs's
3135
// short-circuit (mirror `want_native_uf`).

src/cli.rs

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ struct Args {
128128
/// Bit-exact with the full rebuild; off by default.
129129
#[clap(long = "fast-rebuild")]
130130
fast_rebuild: bool,
131+
/// Native-merge: do congruence INLINE via a native backend merge instead of
132+
/// the rule-encoded `@congruence_rule*` self-join. A constructor's `@<F>View`
133+
/// becomes an FD view `(children) -> eclass` with a side-effecting
134+
/// `MergeFn::UnionId` merge that unions the two eclasses on an FD conflict
135+
/// (same children, different output) at insert time, and the separate
136+
/// congruence rule is dropped from the per-iteration rebuild. This is the
137+
/// non-proof half of egglog PR #933. Implies `--native-uf` + term encoding
138+
/// (the injected union edge goes into the in-core union-find). `--flowlog`
139+
/// only for now (the backend must inject the union on FD-conflict). Term mode
140+
/// only; off by default (the rule-encoded congruence is byte-identical).
141+
#[clap(long = "native-merge")]
142+
native_merge: bool,
131143
/// Native-engine table rebuild for the term-encoding native-UF rebuild
132144
/// (`--nativerb`, native bridge only). Replaces the per-column
133145
/// `@rebuild_rule*` rebuild rules with the ENGINE's native table rebuild
@@ -162,7 +174,24 @@ pub fn cli(mut egraph: EGraph) {
162174
.parse_default_env()
163175
.init();
164176

165-
let args = Args::parse();
177+
let mut args = Args::parse();
178+
179+
// `--native-merge` does congruence inline via a native backend merge that
180+
// injects the union-on-FD-conflict edge into the in-core union-find — so it
181+
// REQUIRES native-UF (the relational `@UF_S` parent table has no insert-time
182+
// merge hook). It is implemented on the FlowLog backend only for now (the
183+
// backend must inject the union on FD-conflict; other backends still map a
184+
// UnionId merge to a plain min-and-drop, which would silently lose the
185+
// congruence edge). Enforce flowlog and fold native-merge into native-UF so
186+
// every downstream `args.native_uf` site (term encoding, backend config) is
187+
// provisioned correctly.
188+
if args.native_merge {
189+
if !args.flowlog_backend {
190+
log::error!("--native-merge is only supported with --flowlog");
191+
std::process::exit(1);
192+
}
193+
args.native_uf = true;
194+
}
166195

167196
// The experimental backends are mutually exclusive: each swaps in a
168197
// different `Backend` impl for the run, so at most one may be selected.
@@ -198,20 +227,36 @@ pub fn cli(mut egraph: EGraph) {
198227
// encoding (enabling it here covers the case where `--term-encoding`
199228
// was not also passed).
200229
//
201-
// Skip this when the caller already handed us a duckdb-backed egraph
202-
// (egglog-experimental's `main` pre-builds one so its commands /
203-
// primitives survive). `with_duckdb_backend` already provisions term
204-
// encoding (a bridge-backed typechecker) without cloning the backend,
205-
// whereas `with_term_encoding_enabled` clones `self` — and the duckdb
206-
// backend's `clone_boxed` is unimplemented, so cloning it panics.
207-
if (args.term_encoding || args.native_uf) && !egraph.has_duckdb_backend() {
230+
// Skip this when the caller already handed us a duckdb/flowlog/feldera-backed
231+
// egraph (egglog-experimental's `main` pre-builds one so its commands /
232+
// primitives survive). Those `with_*_backend_config` constructors already
233+
// provision term encoding (a bridge-backed typechecker) without cloning the
234+
// backend, whereas `with_term_encoding_enabled` clones `self` — and those
235+
// backends' `clone_boxed` is unimplemented, so cloning panics. Plain
236+
// `--flowlog`/`--feldera` (no `--native-uf`/`--term-encoding`) never reach
237+
// here (both flags are false); `--native-uf --flowlog` does, hence the guard.
238+
if (args.term_encoding || args.native_uf)
239+
&& !egraph.has_duckdb_backend()
240+
&& !egraph.has_flowlog_backend()
241+
&& !egraph.has_feldera_backend()
242+
{
208243
egraph = egraph.with_term_encoding_enabled();
209244
}
210245

211246
if args.native_uf {
212247
egraph = egraph.with_native_uf();
213248
}
214249

250+
// `--native-merge` (set after native-UF above, which it implies): switch the
251+
// encoder to the FD-keyed constructor view + inline UnionId merge. The
252+
// backend egraph built below is the one that runs; for the flowlog short-
253+
// circuit path the `with_native_merge` call must land on `backend_eg` too
254+
// (see the experimental backend branch), so set it here on the egraph that
255+
// cli ultimately runs.
256+
if args.native_merge {
257+
egraph = egraph.with_native_merge();
258+
}
259+
215260
// `--fast-rebuild` on the dataflow/SQL backends is wired into each backend's
216261
// config (`config.fast_rebuild` → `enable_fast_rebuild()`) below. On the
217262
// NATIVE BRIDGE (no `--duckdb`/`--feldera`/`--flowlog`) there is no separate
@@ -478,6 +523,13 @@ pub fn cli(mut egraph: EGraph) {
478523
if args.native_uf && (args.flowlog_backend || args.feldera_backend) {
479524
backend_eg = backend_eg.with_native_uf();
480525
}
526+
// `--native-merge` (flowlog only, enforced above): switch the
527+
// rebuilt backend egraph's encoder to the FD-keyed view + inline
528+
// UnionId merge. Must be set on `backend_eg` (the egraph that
529+
// actually runs the program), not the pre-rebuild `egraph`.
530+
if args.native_merge && args.flowlog_backend {
531+
backend_eg = backend_eg.with_native_merge();
532+
}
481533
backend_eg.parser = std::mem::take(&mut egraph.parser);
482534
// Carry over the experimental user-defined commands (run-schedule,
483535
// multi-extract) that egglog-experimental registered. The parser

0 commit comments

Comments
 (0)