Skip to content

perf(optimizer): speed up merge_subqueries#7924

Open
fivetran-kwoodbeck wants to merge 5 commits into
mainfrom
optimizer/performance-merge_subqueries
Open

perf(optimizer): speed up merge_subqueries#7924
fivetran-kwoodbeck wants to merge 5 commits into
mainfrom
optimizer/performance-merge_subqueries

Conversation

@fivetran-kwoodbeck

@fivetran-kwoodbeck fivetran-kwoodbeck commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Performance rework of merge_subqueries with ~1.25x speedup. No changes to output SQL.

Changes

  • Build the scope tree once: merge_subqueries calls traverse_scope once and threads scopes into merge_ctes and merge_derived_tables (was previously built twice).
  • Single-pass scan in _mergeable: one loop over inner_select.selects precomputes projections, number_literal_aliases, window_aliases and does the AggFunc/Select/Explode check, replacing several re-walks. Helpers now receive this data instead of recomputing it.
  • Cache outer, outer_args, and inner_name; convert the boolean and-chain to early-return guards; run _is_recursive only when inner_scope.is_cte.

Performance (pyperf, timing only the rule; before = origin/main, after = this branch).

Corpus Before After Speedup
merge_subqueries_fixtures 17.4 ms +/- 0.4 14.5 ms +/- 0.4 1.20x
merge_subqueries_tpch 8.35 ms +/- 0.18 6.57 ms +/- 0.19 1.27x
merge_subqueries_wide_chain 581 ms +/- 5 446 ms +/- 15 1.30x
merge_subqueries_whole_suite 215 ms +/- 3 175 ms +/- 2 1.23x
Geometric mean ~1.25x

Check the Jira ticket for the timing script.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

SQLGlot Integration Test Results

❌ 3 unit test failures — see details below

Comparing:

  • this branch (sqlglot:optimizer/performance-merge_subqueries @ sqlglot 513959e)
  • baseline (main @ sqlglot 3c7892f)

By Dialect

dialect main feature branch transitions links
bigquery -> bigquery 34273/34695 passed (98.8%) 33125/33540 passed (98.8%) 2 fail -> pass full result / delta
databricks -> databricks 9999/11820 passed (84.6%) 10002/11820 passed (84.6%) 3 fail -> pass full result / delta

Overall

main: 192414 total, 153544 passed (pass rate: 79.8%)

sqlglot:optimizer/performance-merge_subqueries: 180220 total, 142397 passed (pass rate: 79.0%)

Transitions:
5 fail -> pass

Dialect pair changes: 0 previous results not found, 3 current results not found

❌ 3 unit test failures (view logs)

Comment on lines +44 to +46
scopes = traverse_scope(expression)
expression = merge_ctes(expression, leave_tables_isolated, scopes=scopes)
expression = merge_derived_tables(expression, leave_tables_isolated, scopes=scopes)

@georgesittas georgesittas Jul 22, 2026

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.

We can't reuse scopes like this across the two passes, because they both mutate the AST heavily in a way that invalidates the scope's tree structure & the relevant state.

Try this input:

WITH c AS (SELECT a FROM (SELECT a, b FROM x) AS q WHERE q.b > 1)
SELECT c.a FROM c JOIN y ON c.a = y.a

We should add this as a test.

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.

We could change this a bit and reuse scopes if nothing was mutated in merge_ctes, perhaps.

@fivetran-kwoodbeck fivetran-kwoodbeck Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I was concerned about that, but the tests all passed (and I couldn't think up a query that would fail). I'll add that new test in. This part had the most impact in terms of performance gains, so yes, if would be worth checking if anything mutated (and re-initialize the scope if so).

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.

Check these out as well:

-- CTE merge displaces a derived table that outer columns reference
WITH c AS (SELECT a FROM (SELECT a, b FROM x) AS q WHERE q.b > 1) SELECT c.a FROM c JOIN y ON c.a = y.a

-- rename-conflict path
WITH c AS (SELECT q.a FROM (SELECT x.a FROM x AS x) AS q) SELECT c.a FROM c JOIN x ON c.a = x.a

Comment on lines -142 to +152
def _window_projection_blocks_merge():
def _window_projection_blocks_merge(window_aliases):

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.

Let's add type hints in these closures.


outer = outer_scope.expression
if outer.args.get("where") or outer.args.get("joins"):
if outer_args.get("where") or outer_args.get("joins"):

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'd define outer_args before the closures, _mergeable is confusing in its current form. Same goes for other variables (e.g., inner_name) that are defined after them.

Comment on lines +263 to +275
outer = outer_scope.expression
if not isinstance(outer, exp.Select):
return False
if outer.is_star:
return False
if not isinstance(inner_select, exp.Select):
return False
if any(v for k, v in inner_select.args.items() if k in UNMERGABLE_ARGS):
return False
if inner_select.args.get("from_") is None:
return False
if outer_scope.pivots:
return False

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.

Why are these split across N branches instead of combined via conjunction in a single branch?

Comment on lines +314 to +327
if _outer_select_joins_on_inner_select_join(projections):
return False
if _window_projection_blocks_merge(window_aliases):
return False
if _literal_group_unmergeable(number_literal_aliases):
return False
if _literal_in_order_by(number_literal_aliases):
return False
if inner_scope.is_cte and _is_recursive():
return False
if inner_select.args.get("order") and outer_scope.is_union:
return False
if isinstance(seq_get(inner_select.expressions, 0), exp.QueryTransform):
return False

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.

Ditto.

return expression


def _mergeable(

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.

@fivetran-kwoodbeck let's please measure the impact of this refactor alone. I'd avoid the rewrite for now if it's negligent.

@georgesittas georgesittas Jul 22, 2026

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.

Actually, I thought about this more and I'm ok with keeping the rewrite after all, as long as we prove the two forms are equivalent. Splitting up that huge connector chain currently in main seems better for readability and reusing / factoring out logic in the future.

It'd still be interesting to bench this separately, though.

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.

2 participants