1. Problem
Deduplication
When a query contains structurally identical subqueries, each is translated and
decorrelated independently, producing redundant joins that scan and aggregate
the same data multiple times.
SELECT * FROM nation n
WHERE n_nationkey > (SELECT avg(n_nationkey) FROM nation s
WHERE s.n_regionkey = n.n_regionkey)
AND n_regionkey < (SELECT avg(n_nationkey) FROM nation s
WHERE s.n_regionkey = n.n_regionkey)
Both subqueries compute avg(n_nationkey) GROUP BY n_regionkey over the same
table. Without dedup, this produces two LEFT JOINs. After dedup, one suffices —
both filter predicates reference the same result column.
Merging
When subqueries share the same FROM table, same correlation, and same GROUP BY
but differ only in their aggregates, each produces a separate join that scans
and groups the same data.
SELECT * FROM nation n
WHERE n_nationkey > (SELECT avg(n_nationkey) FROM nation s
WHERE s.n_regionkey = n.n_regionkey)
AND n_regionkey < (SELECT max(n_nationkey) FROM nation s
WHERE s.n_regionkey = n.n_regionkey)
Without merge, this produces two LEFT JOINs with separate aggregations. After
merge, one LEFT JOIN with a combined aggregation (avg + max) suffices.
Dimensions of applicability
The following dimensions determine whether two subqueries can be deduplicated or
merged:
By subquery type:
| Type |
Dedup eligible? |
Merge eligible? |
Reason |
| Scalar |
Yes |
Yes |
Result is a single value — duplicates produce identical values; compatible aggregates can be combined. |
| IN |
Yes |
No |
The projected column is the semi-join equality key. Different projections mean different join conditions — merging would change semantics (requiring both conditions to match the same row). |
| EXISTS |
Yes |
No |
The projection is trivial (SELECT 1). Identical FROM + WHERE is already dedup. Different WHERE conditions are semantically different predicates, not mergeable. |
By correlation:
| Correlation |
Dedup eligible? |
Merge eligible? |
Reason |
| Uncorrelated |
Yes |
Yes |
No outer dependency. Same computation → same result. |
| Correlated |
Yes, when same outer references |
Yes, when same outer references |
A correlated subquery is a pure function of the correlation key value and the database state. Same outer references → same function → dedup/merge is correct regardless of outer-table pre-filtering. |
By clause location:
| Location |
Dedup eligible? |
Merge eligible? |
Reason |
Same expression (e.g., WHERE a > sub1 AND b < sub2) |
Yes |
Yes |
Both subqueries are in the same expression tree — visible together. |
| Different expressions in same node (e.g., separate projection columns) |
Yes |
Yes |
Same outer table, same scope. Visible together if expressions are batched. |
| Across filter and projection (same query block) |
Yes |
Yes |
Same outer table, same correlation key values. The subquery result is a pure function independent of which clause references it. |
| Across UNION ALL branches |
Not safely |
Not safely |
Each branch has its own outer table instance. The decorrelated join edge is bound to a specific outer table in a specific DT. Reusing a result column from one branch's DT in another creates dangling references. This is a structural constraint of the query graph model, not a semantic issue. |
2. How Other Engines Handle Dedup and Merge
Overview
| Feature |
Presto (0.297) |
DuckDB (1.4.4) |
Spark (4.0) |
Axiom (proposed) |
| Dedup |
Syntactic identity |
No |
Canonicalized plan |
Positional structural comparison |
| Merge |
No |
No |
Uncorrelated scalar only |
Correlated + uncorrelated scalar |
| When |
Planning time |
— |
Post-decorrelation |
Pre-decorrelation (pre-pass) |
| Handles alias differences |
No |
— |
Yes (canonicalized) |
Yes (positional, ignores names) |
Presto
Deduplicates subqueries via syntactic identity at planning time. In
SubqueryPlanner.java, before creating a new join node (LateralJoinNode for
scalar subqueries, ApplyNode for IN/EXISTS), checks if the expression already
has a mapping in the TranslationMap. Two subqueries that
are semantically identical but syntactically different (e.g., different column
aliases) are NOT deduplicated. No merging support — each subquery becomes a
separate LateralJoinNode and is decorrelated independently.
DuckDB
No deduplication. BoundSubqueryExpression::Equals() always returns false,
preventing the CommonSubExpressionOptimizer from detecting identical
subqueries. No merging support.
Spark
Deduplicates via canonicalized plan comparison in MergeSubplans (formerly
MergeScalarSubqueries). Canonicalization normalizes column names, so
semantically equivalent subqueries with different aliases are deduplicated.
Supports merging uncorrelated scalar subqueries with the same FROM but different
aggregates — tryMergePlans recursively matches plan structures and combines
aggregate lists. Merging is restricted to uncorrelated scalars because the rule
runs after decorrelation — correlated subqueries have already been rewritten
into LEFT JOINs and are no longer visible as subquery expressions. Merged
results are wrapped in a CTE with GetStructField extraction.
Axiom (proposed)
Operates before decorrelation (pre-pass over the logical plan tree), so it
naturally sees both correlated and uncorrelated subqueries. Uses positional
structural comparison (subqueryExprEquivalent) that resolves column references
by ordinal position, making it robust to planner-generated name suffixes. This
is the only engine that supports merging correlated scalar subqueries.
3. Current Architecture: How Subqueries Are Processed
ToGraph::makeQueryGraph() walks the logical plan tree top-down and builds a
flat query graph (DerivedTable with tables, join edges, and conjuncts). When
it encounters a Filter or Project node, it calls processSubqueries to extract
and decorrelate subqueries.
Processing flow per call:
extractSubqueries(expr) — walks the expression tree, classifies each
subquery into scalars, inPredicates, or exists.
- Optionally wraps
currentDt_ via finalizeDt (to prevent self-referencing
join edges when prior decorrelated joins exist).
- For each subquery:
translateSubquery creates a new DT, recursively calls
makeQueryGraph, then the correlated/uncorrelated handler creates the
appropriate join edge.
- Stores the result in
subqueries_[exprPtr] = resultColumn, used later by
translateExpr to replace subquery expressions.
Key constraint: processSubqueries is called per-expression (one for each
projection column, one for the filter predicate). Subqueries in different
expressions are in different calls, separated by potential finalizeDt DT
wrapping. This makes cross-call optimization non-trivial — column references
become stale after wrapping and must be rewritten via exportExpr.
4. Proposed Approach
(A proof-of-concept prototype is available in #1187)
Core idea
Move all deduplication and merging to a single pre-pass over the logical
plan tree, before makeQueryGraph. The pre-pass sees all subqueries globally
and builds a map (subqueryMap_) that records which subqueries are duplicates
and which are mergeable. During query graph construction, processSubqueries
applies this pre-computed map via pointer-identity lookups.
Architecture
┌─────────────────────────────────────────────┐
│ buildSubqueryMap (pre-pass) │
│ │
│ 1. collectAllSubqueries — walk plan tree, │
│ gather all SubqueryExpr/IN/EXISTS │
│ │
│ 2. Dedup — O(n²) comparison via │
│ subqueryExprEquivalent (all types) │
│ │
│ 3. Merge — group remaining scalars by │
│ isSameMergeKey, build merged plans │
│ │
│ Output: subqueryMap_ │
│ duplicate → {representative, 0} │
│ merge original → {merged repr, index} │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ processSubqueries (per-call processing) │
│ │
│ 1. Extract subqueries from expression │
│ │
│ 2. Cross-call check: if exprPtr already │
│ in subqueries_, skip (rewrite via │
│ exportExpr if finalizeDt wrapped) │
│ │
│ 3. Apply subqueryMap_: replace originals │
│ with representatives │
│ │
│ 4. finalizeDt if needed │
│ │
│ 5. Process remaining subqueries │
│ (translateSubquery + decorrelation) │
│ │
│ 6. Store results for all mapped originals │
│ in subqueries_ │
└─────────────────────────────────────────────┘
Key data structures
| Data structure |
Type |
Role |
subqueryMap_ |
F14FastMap<ExprPtr, SubqueryMapEntry> |
Pre-computed map from each duplicate/mergeable original to its representative and output index. Populated once by buildSubqueryMap. |
SubqueryMapEntry |
{ExprPtr representative, size_t outputIndex} |
For dedup: representative is an original ExprPtr, outputIndex is 0. For merge: representative is a synthetic SubqueryExprPtr wrapping a merged AggregateNode, outputIndex identifies the aggregate column. |
subqueries_ |
F14FastMap<ExprPtr, ExprCP> |
Maps subquery expressions to their replacement columns. Populated during processing. Used by translateExpr and by cross-call pointer-identity dedup. |
Comparison functions
| Function |
Purpose |
Scope |
subqueryExprEquivalent |
Unified dedup comparison for all types. Dispatches internally: scalar → isSameComputation on plans; IN → compares form + left key + subquery plan; EXISTS → compares form + subquery plan. |
Dedup |
isSameMergeKey |
Compares two scalar subquery plans for merge compatibility: identical child structure and grouping keys, but aggregate functions may differ. |
Merge |
isSameComputation |
Recursively compares LogicalPlanNode trees positionally (types by position, column references by ordinal). The foundation both above functions build on. |
Both |
How cross-call dedup/merge works
When the pre-pass maps subqueries A (in filter) and B (in projection) to the
same representative R:
-
Filter's processSubqueries: extracts A → subqueryMap_ lookup finds
R → processes R → stores subqueries_[A] and subqueries_[B] (all
originals mapped to R get their results stored immediately).
-
finalizeDt wraps currentDt_ (because a non-inner join was added).
-
Projection's processSubqueries: extracts B →
subqueries_.contains(B) is true (stored in step 1) → B is added to
alreadyProcessed → its stale column reference is rewritten via
exportExpr through the wrapper. No new join created.
This reuses the existing exportExpr rewriting infrastructure that was
already in place for pointer-identity cross-call dedup.
Merged plan construction
For a merge group of scalar subqueries with the same merge key:
-
buildMergedPlan creates a new AggregateNode that combines all group
members' aggregates, reusing the representative's child subtree.
-
Aggregate expressions from non-representative members have their
InputReferenceExpr names remapped to the representative child's column
namespace via remapAggregateInputNames.
-
The merged node is registered in the subfield tracker via
registerAllChannelsAsUsed (since it's a synthetic node not seen by the
original subfield tracking pass).
Scope boundaries
collectAllSubqueries stops at set operation nodes (kSet — UNION, INTERSECT,
etc.). buildSubqueryMap recurses into each branch independently. This ensures
subqueries from different union branches are never grouped together — each
branch is an independent scope with its own currentDt_ during
makeQueryGraph.
5. Capability Summary
What can be deduplicated
| Dimension |
Supported? |
Reason |
| Scalar subqueries |
Yes |
Full structural comparison via isSameComputation. |
| IN subqueries |
Yes |
Compares form + left key + subquery plan via subqueryExprEquivalent. |
| EXISTS subqueries |
Yes |
Compares form + subquery plan via subqueryExprEquivalent. |
| Correlated |
Yes |
Outer references are part of the plan tree; structural comparison covers them. |
| Uncorrelated |
Yes |
No outer dependency — same computation → same result. |
| Same expression |
Yes |
Extracted into the same batch. |
| Across projection columns |
Yes |
Projection expressions batched into a single processSubqueries call. |
| Across filter and projection |
Yes |
Pre-pass sees both globally; cross-call exportExpr rewriting handles finalizeDt wrapping. |
| Across UNION branches |
No |
Each branch has its own outer table instance; join edges are DT-specific. Reusing columns across branches creates dangling references. This is a structural constraint of the query graph model. |
What can be merged
| Dimension |
Supported? |
Reason |
| Scalar subqueries |
Yes |
Aggregates from different subqueries combined into one AggregateNode. |
| IN subqueries |
No (semantic) |
The projected column is the semi-join equality key. Different projections → different join conditions → different semantics. |
| EXISTS subqueries |
No (semantic) |
Projection is trivial (SELECT 1). Identical FROM + WHERE → dedup. Different WHERE → semantically different predicates. |
| Correlated |
Yes |
Pre-pass operates before decorrelation, so correlated subqueries are still visible as SubqueryExpr nodes. |
| Uncorrelated |
Yes |
Same as correlated, but uncorrelated global aggregations over bare scans are excluded to preserve constant folding (merging would replace two independently-foldable literals with a cross-join). |
| Same expression |
Yes |
Pre-pass sees all subqueries globally. |
| Across projection columns |
Yes |
Same as above. |
| Across filter and projection |
Yes |
Same as above — pre-pass + cross-call exportExpr rewriting. |
| Across UNION branches |
No |
Same structural constraint as dedup. |
Comparison with other engines
| Capability |
Presto |
DuckDB |
Spark |
Axiom (proposed) |
| Dedup scalar |
Syntactic only |
No |
Uncorrelated only |
All (correlated + uncorrelated) |
| Dedup IN |
Syntactic only |
No |
No |
Yes |
| Dedup EXISTS |
Syntactic only |
No |
No |
Yes |
| Dedup across clauses |
No |
No |
No |
Yes (filter + projection) |
| Merge scalar |
No |
No |
Uncorrelated only |
All (correlated + uncorrelated) |
| Merge across clauses |
No |
No |
No |
Yes (filter + projection) |
| Handles alias differences |
No |
No |
Yes |
Yes |
1. Problem
Deduplication
When a query contains structurally identical subqueries, each is translated and
decorrelated independently, producing redundant joins that scan and aggregate
the same data multiple times.
Both subqueries compute
avg(n_nationkey) GROUP BY n_regionkeyover the sametable. Without dedup, this produces two LEFT JOINs. After dedup, one suffices —
both filter predicates reference the same result column.
Merging
When subqueries share the same FROM table, same correlation, and same GROUP BY
but differ only in their aggregates, each produces a separate join that scans
and groups the same data.
Without merge, this produces two LEFT JOINs with separate aggregations. After
merge, one LEFT JOIN with a combined aggregation (
avg+max) suffices.Dimensions of applicability
The following dimensions determine whether two subqueries can be deduplicated or
merged:
By subquery type:
SELECT 1). Identical FROM + WHERE is already dedup. Different WHERE conditions are semantically different predicates, not mergeable.By correlation:
By clause location:
WHERE a > sub1 AND b < sub2)2. How Other Engines Handle Dedup and Merge
Overview
Presto
Deduplicates subqueries via syntactic identity at planning time. In
SubqueryPlanner.java, before creating a new join node (LateralJoinNodeforscalar subqueries,
ApplyNodefor IN/EXISTS), checks if the expression alreadyhas a mapping in the
TranslationMap. Two subqueries thatare semantically identical but syntactically different (e.g., different column
aliases) are NOT deduplicated. No merging support — each subquery becomes a
separate
LateralJoinNodeand is decorrelated independently.DuckDB
No deduplication.
BoundSubqueryExpression::Equals()always returnsfalse,preventing the
CommonSubExpressionOptimizerfrom detecting identicalsubqueries. No merging support.
Spark
Deduplicates via canonicalized plan comparison in
MergeSubplans(formerlyMergeScalarSubqueries). Canonicalization normalizes column names, sosemantically equivalent subqueries with different aliases are deduplicated.
Supports merging uncorrelated scalar subqueries with the same FROM but different
aggregates —
tryMergePlansrecursively matches plan structures and combinesaggregate lists. Merging is restricted to uncorrelated scalars because the rule
runs after decorrelation — correlated subqueries have already been rewritten
into LEFT JOINs and are no longer visible as subquery expressions. Merged
results are wrapped in a CTE with
GetStructFieldextraction.Axiom (proposed)
Operates before decorrelation (pre-pass over the logical plan tree), so it
naturally sees both correlated and uncorrelated subqueries. Uses positional
structural comparison (
subqueryExprEquivalent) that resolves column referencesby ordinal position, making it robust to planner-generated name suffixes. This
is the only engine that supports merging correlated scalar subqueries.
3. Current Architecture: How Subqueries Are Processed
ToGraph::makeQueryGraph()walks the logical plan tree top-down and builds aflat query graph (
DerivedTablewith tables, join edges, and conjuncts). Whenit encounters a Filter or Project node, it calls
processSubqueriesto extractand decorrelate subqueries.
Processing flow per call:
extractSubqueries(expr)— walks the expression tree, classifies eachsubquery into
scalars,inPredicates, orexists.currentDt_viafinalizeDt(to prevent self-referencingjoin edges when prior decorrelated joins exist).
translateSubquerycreates a new DT, recursively callsmakeQueryGraph, then the correlated/uncorrelated handler creates theappropriate join edge.
subqueries_[exprPtr] = resultColumn, used later bytranslateExprto replace subquery expressions.Key constraint:
processSubqueriesis called per-expression (one for eachprojection column, one for the filter predicate). Subqueries in different
expressions are in different calls, separated by potential
finalizeDtDTwrapping. This makes cross-call optimization non-trivial — column references
become stale after wrapping and must be rewritten via
exportExpr.4. Proposed Approach
(A proof-of-concept prototype is available in #1187)
Core idea
Move all deduplication and merging to a single pre-pass over the logical
plan tree, before
makeQueryGraph. The pre-pass sees all subqueries globallyand builds a map (
subqueryMap_) that records which subqueries are duplicatesand which are mergeable. During query graph construction,
processSubqueriesapplies this pre-computed map via pointer-identity lookups.
Architecture
Key data structures
subqueryMap_F14FastMap<ExprPtr, SubqueryMapEntry>buildSubqueryMap.SubqueryMapEntry{ExprPtr representative, size_t outputIndex}representativeis an original ExprPtr,outputIndexis 0. For merge:representativeis a syntheticSubqueryExprPtrwrapping a mergedAggregateNode,outputIndexidentifies the aggregate column.subqueries_F14FastMap<ExprPtr, ExprCP>translateExprand by cross-call pointer-identity dedup.Comparison functions
subqueryExprEquivalentisSameComputationon plans; IN → compares form + left key + subquery plan; EXISTS → compares form + subquery plan.isSameMergeKeyisSameComputationLogicalPlanNodetrees positionally (types by position, column references by ordinal). The foundation both above functions build on.How cross-call dedup/merge works
When the pre-pass maps subqueries A (in filter) and B (in projection) to the
same representative R:
Filter's
processSubqueries: extracts A →subqueryMap_lookup findsR → processes R → stores
subqueries_[A]andsubqueries_[B](alloriginals mapped to R get their results stored immediately).
finalizeDtwrapscurrentDt_(because a non-inner join was added).Projection's
processSubqueries: extracts B →subqueries_.contains(B)is true (stored in step 1) → B is added toalreadyProcessed→ its stale column reference is rewritten viaexportExprthrough the wrapper. No new join created.This reuses the existing
exportExprrewriting infrastructure that wasalready in place for pointer-identity cross-call dedup.
Merged plan construction
For a merge group of scalar subqueries with the same merge key:
buildMergedPlancreates a newAggregateNodethat combines all groupmembers' aggregates, reusing the representative's child subtree.
Aggregate expressions from non-representative members have their
InputReferenceExprnames remapped to the representative child's columnnamespace via
remapAggregateInputNames.The merged node is registered in the subfield tracker via
registerAllChannelsAsUsed(since it's a synthetic node not seen by theoriginal subfield tracking pass).
Scope boundaries
collectAllSubqueriesstops at set operation nodes (kSet— UNION, INTERSECT,etc.).
buildSubqueryMaprecurses into each branch independently. This ensuressubqueries from different union branches are never grouped together — each
branch is an independent scope with its own
currentDt_duringmakeQueryGraph.5. Capability Summary
What can be deduplicated
isSameComputation.subqueryExprEquivalent.subqueryExprEquivalent.processSubqueriescall.exportExprrewriting handlesfinalizeDtwrapping.What can be merged
AggregateNode.SELECT 1). Identical FROM + WHERE → dedup. Different WHERE → semantically different predicates.SubqueryExprnodes.exportExprrewriting.Comparison with other engines