Skip to content

Improve parallel scaling of e-matching - #954

Draft
ezrosent wants to merge 2 commits into
mainfrom
ezr-better-parallel
Draft

Improve parallel scaling of e-matching#954
ezrosent wants to merge 2 commits into
mainfrom
ezr-better-parallel

Conversation

@ezrosent

@ezrosent ezrosent commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

(branched off of perf-shared-trie-nodes)

This change improves parallel e-matching scaling with a few optimizations:

  • Avoids highly-contended Arc::clone calls with index construction: handles on shared indexes are grabbed before the plan starts running to avoid big clone calls. This alone was a large win.
  • Add a "fast path" where the top relation is coarse-grained parallelized rather than the fine-grained behavior we had before. This is another big speedup, when the top relation is large enough. (Removing Arc clones was still higher though!).
  • Reintroduce a limited form of work-stealing by adding a separate spawn_local method onto the thread pool that allows for work to be appended to a local deque first, only migrating to the global queue if sufficient threads are stalled. This appears to help cases where there isn't a lot of parallelism to expose. The thread pool essentially sprayed data randomly across threads, which makes cache locality much harder to achieve. (The original morsel-driven parallelism paper talks about this too). Local spawns maintain locality while still improving parallel utilization if there's a lot of skew.

To evaluate this I had codex write up a benchmark using the dataset and queries from the Honeycomb paper. This is just a single query over a large dataset, so it's a helpful test case for this part of the code: other egglog benchmarks have complex schedules and many rules that we parallelize across sometimes.

Here are the overall results on my m4 max laptop:

(Note that M4 max only has 12 P-cores, so some amount of flattening after 12 is expected. Still, I suspect there's more to do here)

Workload 1 thread 8 threads 12 threads 16 threads
Honeycomb q6/count 9.271s → 9.126s (1.02×) 3.693s → 1.778s (2.08×) 3.476s → 1.480s (2.35×) 3.691s → 1.411s (2.62×)
gemma.egg 3.436s → 3.615s (0.95×) 1.462s → 1.214s (1.20×) 1.524s → 1.200s (1.27×) 1.651s → 1.273s (1.30×)
gemma4_moe.egg 12.366s → 12.864s (0.96×) 4.695s → 3.865s (1.21×) 4.748s → 3.713s (1.28×) 4.870s → 3.894s (1.25×)
hardboiled_conv1d_128.egg 0.220s → 0.219s (1.00×) 0.256s → 0.210s (1.22×) 0.339s → 0.286s (1.19×) 0.415s → 0.330s (1.26×)
hardboiled_conv1d_32.egg 0.089s → 0.090s (0.99×) 0.106s → 0.107s (1.00×) 0.109s → 0.109s (1.00×) 0.115s → 0.116s (0.99×)
llama.egg 0.306s → 0.308s (1.00×) 0.340s → 0.345s (0.99×) 0.344s → 0.346s (1.00×) 0.350s → 0.350s (1.00×)
luminal-llama.egg 0.065s → 0.081s (0.80×) 0.078s → 0.095s (0.82×) 0.085s → 0.102s (0.83×) 0.085s → 0.102s (0.83×)
paged_llama.egg 1.204s → 1.204s (1.00×) 1.262s → 1.262s (1.00×) 1.289s → 1.295s (1.00×) 1.281s → 1.287s (0.99×)
qwen.egg 0.349s → 0.354s (0.99×) 0.394s → 0.364s (1.08×) 0.423s → 0.391s (1.08×) 0.446s → 0.407s (1.09×)
qwen3_moe.egg 0.458s → 0.466s (0.98×) 0.502s → 0.440s (1.14×) 0.535s → 0.468s (1.14×) 0.587s → 0.510s (1.15×)
whisper.egg 0.994s → 1.018s (0.98×) 0.742s → 0.605s (1.23×) 0.784s → 0.629s (1.25×) 0.868s → 0.679s (1.28×)
Geomean, .egg workloads 0.96× 1.08× 1.09× 1.10×

@ezrosent
ezrosent requested a review from a team as a code owner July 19, 2026 22:55
@ezrosent
ezrosent requested review from yihozhang and removed request for a team July 19, 2026 22:55
@ezrosent
ezrosent marked this pull request as draft July 19, 2026 22:55
@codecov-commenter

codecov-commenter commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.95984% with 100 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.77%. Comparing base (53b9721) to head (90ebda4).

Files with missing lines Patch % Lines
core-relations/src/free_join/execute.rs 83.94% 92 Missing ⚠️
concurrency/src/threadpool/mod.rs 97.89% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #954      +/-   ##
==========================================
+ Coverage   86.59%   86.77%   +0.17%     
==========================================
  Files          95       95              
  Lines       29676    30572     +896     
==========================================
+ Hits        25699    26528     +829     
- Misses       3977     4044      +67     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Jul 19, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 22.84%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 36 untouched benchmarks
⏩ 227 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation tests[luminal-llama] 504.2 ms 653.4 ms -22.84%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ezr-better-parallel (90ebda4) with main (53b9721)

Open in CodSpeed

Footnotes

  1. 227 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@yihozhang yihozhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had some nits and questions. I didn't carefully review the new threadpool implementation besides the high-level description. I also didn't carefully read the new tests.

// immutable plans before any worker starts. A slot acquires and refreshes
// its Arc through the regular catalog helper on first cached use. The
// sidecars are dropped before `merge_all` resets catalog entries.
let prepared_plans = rule_set

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: call them prepared_indexes instead?

}
match plan {
Plan::SinglePlan(plan) => {
match (plan, prepared_plan) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: prepared_index?

let prober =
self.get_column_index(atoms, binding_info, scan.atom, scan.column, prepared);
let size = prober.len();
// The two-way hot path historically breaks ties in favor of the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's cleaner to instead change run_plan to match the natural tie-breaking behavior here.

for i in start..instrs.len() {
if matches!(
&instrs[i],
&instrs[order.get(i)],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it true that i == order.get(i) at this point?

// Count how many times each atom has been refined so far.
for ins in instrs[..range.start].iter() {
match ins {
for position in 0..range.start {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i also don't quite understand this change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is bad! good catch. It's fixed in a follow-up and I can try and backport it if the rest looks good.

// serially. Besides avoiding an intermediate key copy, this keeps
// related nested probes on one worker. Buffers that cannot construct an
// independent partition (the in-place executors) decline this path.
if !stages.instrs.is_empty()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea that if the top level has enough entries, we will only do top-level parallelism? This coarser-grained parallelism can be technically more sensitive to skews but it does not show up in practice and it avoids some overheads with nested parallelism?

Have you measured the percentage of hits on this path versus the nested parallelism path? I feel this top-level parallelism may overshadow the default nested parallelism paths for most of the rules.

/// coarse partition also passes `index_shard`; recursive calls clear it so
/// only the first intersection is restricted to that physical shard.
#[allow(clippy::too_many_arguments)]
fn run_plan<'buf, A: NumericId + 'buf, BUF: ActionBuffer<'buf, A>>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe document the assumption that index_shard.some() implies no more parallelism at this level.

action: ActionId,
bindings: &DenseIdMap<Variable, Value>,
mut to_exec_state: impl FnMut() -> ExecutionState<'scope>,
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we set self.needs_flush = true; here? (was comparing this and ScopedActionBuffer::push_bindings)

&mut self.batches,
self.rule_set,
self.match_counter.as_ref(),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we set self.needs_flush = false;?

@yihozhang

yihozhang commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

From running uv run scripts/bench.py run perf-shared-trie-nodes ezr-better-parallel (serial mode).

# Benchmark diff

  Generated  : 2026-07-25T14:17:12
  Baseline   : 225ad44d (225ad44d2379)  —  add a comment
  Comparison : a384d253 (a384d25325da)  —  Remove retired generic-join tuning paths

  Benchmark                                  Before (s)   After (s)     Δ (s)      Δ %
  ────────────────────────────────────────────────────────────────────────────────────────
  hardboiled_conv1d_32.egg                        0.118       0.115   -0.003    -2.5%  ▼ faster
  hardboiled_conv1d_128.egg                       0.301       0.291   -0.010    -3.3%  ▼ faster
  luminal-llama.egg                               0.083       0.111  +  0.028  +  33.5%  ▲ slower
  python_array_optimize.egg                       0.233       0.235  +  0.002  +   0.8%  ▲ slower
  cykjson.egg                                     0.032       0.034  +  0.002  +   5.5%  ▲ slower
  eggcc-extraction.egg                            0.203       0.216  +  0.012  +   6.0%  ▲ slower
  llama.egg                                       0.373       0.379  +  0.006  +   1.7%  ▲ slower
  paged_llama.egg                                 1.616       1.634  +  0.018  +   1.1%  ▲ slower
  qwen.egg                                        0.424       0.447  +  0.023  +   5.4%  ▲ slower
  qwen3_moe.egg                                   0.586       0.588  +  0.002  +   0.3%  ·
  whisper.egg                                     1.249       1.278  +  0.029  +   2.3%  ▲ slower

Update: numbers from nightly are mostly consistent with the above

But the packed trie PR #959 shows some significant speedups.

@ezrosent

Copy link
Copy Markdown
Contributor Author

I think the llama slowdown is due to the buggy variable ordering heuristic that codex snuck in; it's reverted in the packed-trie branch.

ezrosent added 2 commits July 30, 2026 10:53
Add private worker queues and scheduler metrics, partition generic joins by physical index shard, and retain prepared index handles for recursive probes.
@ezrosent
ezrosent force-pushed the ezr-better-parallel branch from a384d25 to 90ebda4 Compare July 30, 2026 17:57
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.

3 participants