From ee3a86509f9483ae81dd8f1e6ad9c06ad8d9da9d Mon Sep 17 00:00:00 2001 From: Oliver Flatt Date: Mon, 20 Jul 2026 02:56:15 +0000 Subject: [PATCH 1/3] core-relations: reset only modified tables' indexes in merge_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Database::merge_all` reset the cached column/key indexes of *every* table and re-summed every table's length on each call, regardless of which tables actually changed. That is O(all tables) per call, and quadratic when many small tables each trigger a merge (e.g. workloads that create many small functions and run short rule sets between them). The full reset is unnecessary: an unmodified table's version is unchanged, so its cached index would be a no-op to refresh anyway. Track the tables actually modified during the call — the union of every `notification_list.reset()` batch, accumulated here and in `merge_simple` — and reset only those. `total_size_estimate` is maintained incrementally at each merge (mirroring `merge_table`) instead of re-summed. Correctness rests on `touched` containing every table whose version bumps this call: `ResettableOnceLock::get_or_update` runs an index refresh only after a `reset()`, so a modified-but-unreset table would serve a stale cached index. Every merged table is drawn from `notification_list.reset()`, which is exactly what `touched` accumulates. `egglog-core-relations` tests (52) and the full `egglog` test suite pass unchanged. --- CHANGELOG.md | 1 + core-relations/src/free_join/mod.rs | 63 ++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b53c909..3928e9114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - ReleaseDate +- Speed up `core-relations`' `merge_all` by resetting only the tables that changed during the call instead of every table, avoiding work that grew with the total number of tables. - Speed up query evaluation by building on-the-fly per-subset column indexes as sorted arrays (`SortedColumnIndex`) instead of hash maps. These indexes are typically iterated once and probed a bounded number of times over high-cardinality columns, so skipping hash-table construction is a large win (e.g. ~33% faster on the `gemma` benchmark). - Share trie roots (and their cached sub-indexes and child nodes) across query plans within a single `run_rule_set` instead of rebuilding a fresh trie per plan. Plans that scan the same table under the same header (fast) constraints reuse one root, so on-the-fly per-subset index builds happen once rather than per plan; only roots that more than one plan uses are shared, so workloads that would not benefit keep the per-plan behavior. Large speedups on transformer workloads (e.g. ~15% faster on `whisper`, ~12% on `gemma`, ~8% on `qwen3_moe`). - Add `make nightly` and `scripts/nightly_bench.py`, a hyperfine-based benchmark harness that measures every `tests/**/*.egg` program at 1/2/4/8 threads and (where supported) in proof-testing mode, caps each run at a 2-minute timeout, skips sub-50ms programs, and emits an HTML dashboard (one row per benchmark, one column per configuration) for nightly.cs.washington.edu. The dashboard uses [eval-live](https://github.com/oflatt/eval-live) for interactive filtering and sorting. diff --git a/core-relations/src/free_join/mod.rs b/core-relations/src/free_join/mod.rs index 97417988f..f3b5bd627 100644 --- a/core-relations/src/free_join/mod.rs +++ b/core-relations/src/free_join/mod.rs @@ -520,11 +520,19 @@ impl Database { let mut ever_changed = false; let do_parallel = parallelize_db_level_op(self.total_size_estimate); let mut to_merge = IndexSet::default(); + // Tables modified during this call (accumulated from the notification list + // here and inside `merge_simple`). Only these need their cached indexes + // reset at the end: an unmodified table's index is still valid — its + // version is unchanged, so `Index::refresh` would be a no-op. Resetting + // *every* table instead is O(all tables) per call, which is quadratic when + // many small tables each trigger a merge. + let mut touched: IndexSet = IndexSet::default(); loop { to_merge.clear(); let to_merge_vec = self.notification_list.reset(); + touched.extend(to_merge_vec.iter().copied()); if to_merge_vec.len() < 4 { - ever_changed |= self.merge_simple(to_merge_vec); + ever_changed |= self.merge_simple(to_merge_vec, &mut touched); break; } for table in to_merge_vec { @@ -556,7 +564,13 @@ impl Database { // Then initialize read dependencies (this two-phase structure is why we have an // Option in the tables_merging map). for table in stratum.intersection(&to_merge).copied() { - tables_merging[table].0 = Some(self.tables.unwrap_val(table)); + let val = self.tables.unwrap_val(table); + // Maintain `total_size_estimate` incrementally (subtract now, add + // the post-merge length on drain below) so the reset loop no + // longer re-sums every table. + self.total_size_estimate = + self.total_size_estimate.wrapping_sub(val.table.len()); + tables_merging[table].0 = Some(val); } let db = self.read_only_view(); changed |= if do_parallel { @@ -577,39 +591,58 @@ impl Database { .unwrap_or(false) }; for (id, (table, _)) in tables_merging.drain() { - self.tables.insert(id, table.unwrap()); + let val = table.unwrap(); + self.total_size_estimate = + self.total_size_estimate.wrapping_add(val.table.len()); + self.tables.insert(id, val); } } ever_changed |= changed; } - // Reset all indexes to force an update on the next access. - let mut size_estimate = 0; - for (_, info) in self.tables.iter_mut() { - info.column_indexes.update(|_, ti| { - Arc::get_mut(ti).unwrap().reset(); - }); - info.indexes.update(|_, ti| { - Arc::get_mut(ti).unwrap().reset(); - }); - size_estimate += info.table.len(); + // Reset the cached indexes of only the tables modified during this call so + // they refresh on next access; unmodified tables keep their still-valid + // cached indexes. `touched` must contain *every* table whose version bumped + // this call: `ResettableOnceLock::get_or_update` runs the index `refresh` + // only after a `reset()`, so a modified-but-unreset table would keep serving + // a stale cached index. It does — every merged table comes from + // `notification_list.reset()`, which is exactly what `touched` accumulates. + // `total_size_estimate` was maintained incrementally at each merge (above and + // in `merge_simple`), so we no longer re-sum every table here. + for table in touched.iter().copied() { + if let Some(info) = self.tables.get_mut(table) { + info.column_indexes.update(|_, ti| { + Arc::get_mut(ti).unwrap().reset(); + }); + info.indexes.update(|_, ti| { + Arc::get_mut(ti).unwrap().reset(); + }); + } } - self.total_size_estimate = size_estimate; ever_changed } /// A "fast path" merge method that is not optimized for parallelism and does not respect read /// and write dependencies. This ends up being faster than the full "strata-aware" option in /// the body of `merge_all`. - fn merge_simple(&mut self, mut to_merge: SmallVec<[TableId; 4]>) -> bool { + fn merge_simple( + &mut self, + mut to_merge: SmallVec<[TableId; 4]>, + touched: &mut IndexSet, + ) -> bool { let mut changed = false; while !to_merge.is_empty() { for table_id in to_merge.iter().copied() { let mut info = self.tables.unwrap_val(table_id); + // Maintain `total_size_estimate` incrementally (see `merge_all`'s + // reset loop, which no longer re-sums every table). + self.total_size_estimate = self.total_size_estimate.wrapping_sub(info.table.len()); let mut es = ExecutionState::new(self.read_only_view(), Default::default()); changed |= info.table.merge(&mut es).added || es.changed; + self.total_size_estimate = self.total_size_estimate.wrapping_add(info.table.len()); self.tables.insert(table_id, info); } to_merge = self.notification_list.reset(); + touched.extend(to_merge.iter().copied()); } changed } From af91b80c8306eda102d49200c04555bc373cc473 Mon Sep 17 00:00:00 2001 From: Oliver Flatt Date: Fri, 24 Jul 2026 16:51:54 -0700 Subject: [PATCH 2/3] don't edit changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3928e9114..96b53c909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## [Unreleased] - ReleaseDate -- Speed up `core-relations`' `merge_all` by resetting only the tables that changed during the call instead of every table, avoiding work that grew with the total number of tables. - Speed up query evaluation by building on-the-fly per-subset column indexes as sorted arrays (`SortedColumnIndex`) instead of hash maps. These indexes are typically iterated once and probed a bounded number of times over high-cardinality columns, so skipping hash-table construction is a large win (e.g. ~33% faster on the `gemma` benchmark). - Share trie roots (and their cached sub-indexes and child nodes) across query plans within a single `run_rule_set` instead of rebuilding a fresh trie per plan. Plans that scan the same table under the same header (fast) constraints reuse one root, so on-the-fly per-subset index builds happen once rather than per plan; only roots that more than one plan uses are shared, so workloads that would not benefit keep the per-plan behavior. Large speedups on transformer workloads (e.g. ~15% faster on `whisper`, ~12% on `gemma`, ~8% on `qwen3_moe`). - Add `make nightly` and `scripts/nightly_bench.py`, a hyperfine-based benchmark harness that measures every `tests/**/*.egg` program at 1/2/4/8 threads and (where supported) in proof-testing mode, caps each run at a 2-minute timeout, skips sub-50ms programs, and emits an HTML dashboard (one row per benchmark, one column per configuration) for nightly.cs.washington.edu. The dashboard uses [eval-live](https://github.com/oflatt/eval-live) for interactive filtering and sorting. From 41e2e89286e47b2748f9d4ea3758e7c4d260f812 Mon Sep 17 00:00:00 2001 From: Oliver Flatt Date: Fri, 24 Jul 2026 17:03:49 -0700 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- core-relations/src/free_join/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/core-relations/src/free_join/mod.rs b/core-relations/src/free_join/mod.rs index f3b5bd627..b45239da5 100644 --- a/core-relations/src/free_join/mod.rs +++ b/core-relations/src/free_join/mod.rs @@ -520,12 +520,8 @@ impl Database { let mut ever_changed = false; let do_parallel = parallelize_db_level_op(self.total_size_estimate); let mut to_merge = IndexSet::default(); - // Tables modified during this call (accumulated from the notification list - // here and inside `merge_simple`). Only these need their cached indexes - // reset at the end: an unmodified table's index is still valid — its - // version is unchanged, so `Index::refresh` would be a no-op. Resetting - // *every* table instead is O(all tables) per call, which is quadratic when - // many small tables each trigger a merge. + // Tables modified during this `merge_all` call. Only these need their cached indexes reset + // at the end so future reads refresh them. let mut touched: IndexSet = IndexSet::default(); loop { to_merge.clear();