Merge expressions into join during join reordering by vdimir · Pull Request #98533 · ClickHouse/ClickHouse · GitHub
Skip to content

Merge expressions into join during join reordering#98533

Open
vdimir wants to merge 21 commits into
masterfrom
vdimir/join_reorder_through_expression
Open

Merge expressions into join during join reordering#98533
vdimir wants to merge 21 commits into
masterfrom
vdimir/join_reorder_through_expression

Conversation

@vdimir

@vdimir vdimir commented Mar 2, 2026

Copy link
Copy Markdown
Member

Changelog category (leave one):

  • Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

  • Added the query_plan_merge_expression_into_join setting to allow merging Expression steps into JOIN steps during join reordering optimization. This enables join reordering across subqueries that wrap joins (e.g., when a JOIN is inside a subquery with computed columns), leading to better optimization of complex join trees.

@clickhouse-gh

clickhouse-gh Bot commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

@clickhouse-gh clickhouse-gh Bot added the pr-improvement Pull request with some product improvements label Mar 2, 2026
@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from 1ccd131 to 7cba228 Compare March 11, 2026 10:12
Comment thread src/Processors/QueryPlan/Optimizations/optimizeJoin.cpp Outdated
@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from 7cba228 to 19e7334 Compare March 11, 2026 13:40
Comment thread src/Processors/QueryPlan/Optimizations/optimizeJoin.cpp
@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from 19e7334 to 16d7279 Compare March 11, 2026 13:48

SET enable_parallel_replicas = 0;
SET query_plan_join_swap_table = 'auto';
SET query_plan_optimize_join_order_limit = 64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice coverage for the enabled path. To reduce regression risk for the new switch, please add one companion case with query_plan_merge_expression_into_join = 0 on the same query shape (plan + result), so both enabled and disabled semantics are locked by tests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-checked the current 04001 test and still don't see a companion case with query_plan_merge_expression_into_join = 0 for the same query shape.

Since this PR introduces a switchable behavior, we should lock both branches (1 and 0) with plan+result checks to prevent regressions where the disabled path drifts silently.

@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from c3d87b4 to 6e16799 Compare March 11, 2026 14:48
Comment thread src/Processors/QueryPlan/JoinStepLogical.cpp Outdated
@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from f6bfae3 to 73d2ea8 Compare March 16, 2026 11:53
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
Comment thread tests/queries/0_stateless/04001_join_reorder_through_expression.sql
@vdimir vdimir changed the title wip merge expression into join Merge expressions into join during join reordering Mar 24, 2026
@vdimir vdimir marked this pull request as ready for review March 24, 2026 16:08
@antaljanosbenjamin antaljanosbenjamin self-assigned this Mar 27, 2026
@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch from 286d053 to 430261c Compare April 1, 2026 11:43
{
bool merge_expression_into_join = query_graph.context->optimization_settings.merge_expression_into_join;
auto * expr = typeid_cast<ExpressionStep *>(check->step.get());
if (expr && check->children.size() == 1 && !expr->getExpression().hasArrayJoin()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think checking that it has a single child is unnecessary and confusing, as ExpressionStep can only have one child, if I am not mistaken. Or do I miss something here?

Suggested change
if (expr && check->children.size() == 1 && !expr->getExpression().hasArrayJoin()
if (expr && !expr->getExpression().hasArrayJoin()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ExpressionStep is logically unary, but in this optimizer path we dereference node->children[0] and may run before all structural assumptions are guaranteed. Keeping node->children.size() == 1 here is a defensive guard against malformed/intermediate plan nodes and avoids an avoidable exception in this hot path.

Comment thread src/Processors/QueryPlan/JoinStepLogical.cpp Outdated
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
/// have the correct row count. When all post-join outputs are constants
/// (e.g. `__join_result_dummy`), residual_dag has no required inputs,
/// and the join would produce blocks with 0 columns
if (!left_dag.getOutputs().empty())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If actions_after_join contains only COLUMN constants (e.g. SELECT 1 FROM t1 CROSS JOIN t2 after reorder), residual_dag.getRequiredColumnsNames() is empty and this fallback takes left_dag.getOutputs().front()->result_name. For that shape, the first output can be __join_result_dummy (a fromNone constant), which is not present in left/right input columns. Then TableJoin::setUsedColumns throws NOT_FOUND_COLUMN_IN_BLOCK instead of executing the query.

Please pick a guaranteed real input column (from left_dag.getInputs()/right_dag.getInputs() or child headers) rather than an arbitrary output node.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This still looks live in the current code.

chooseJoinOrder now explicitly appends COLUMN constants (for example __join_result_dummy) to actions_after_join at optimizeJoin.cpp:1263-1267. In buildPhysicalJoinImpl, when residual_dag.getRequiredColumnsNames() is empty, the fallback still picks left_dag.getOutputs().front()->result_name (JoinStepLogical.cpp:1295-1297). Because used_expressions is seeded from actions_after_join first (JoinStepLogical.cpp:1207-1221), that front output can be the fromNone constant instead of a real input column.

Then TableJoin::setUsedColumns receives a name that is not present in either side and throws NOT_FOUND_COLUMN_IN_BLOCK.

Could we change this fallback to take a guaranteed real input from left_dag.getInputs() / right_dag.getInputs() (or child headers), and skip COLUMN nodes here?

@alexey-milovidov

Copy link
Copy Markdown
Member

The Stress test (arm_msan) failure is fixed by #101239, which should be merged first. After it is merged, please update the branch to include the fix.

@alexey-milovidov

Copy link
Copy Markdown
Member

The MSan stress test failure (MemorySanitizer: use-of-uninitialized-value, STID 4179-5154 or 4148-3044) is a known pre-existing issue unrelated to this PR. Fix: #102158

@vdimir vdimir force-pushed the vdimir/join_reorder_through_expression branch 2 times, most recently from 3658658 to 78fbe40 Compare May 26, 2026 14:49
if (expr && !expr->getExpression().hasArrayJoin()
&& (isPassthroughActions(expr->getExpression()) || merge_expression_into_join))
{
check = check->children[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This branch unwraps ExpressionStep with check = check->children[0], but unlike addChildQueryGraph above it does not guard check->children.size() == 1 first.

In this optimizer path we can still see intermediate/malformed nodes, and operator[] here is unchecked. A zero-child ExpressionStep would trigger undefined behavior before we fail with a proper exception.

Please add the same defensive arity check used in addChildQueryGraph before dereferencing children[0].

@ClickHouse ClickHouse deleted a comment from clickhouse-gh Bot Jun 1, 2026
Comment thread src/Core/SettingsChangesHistory.cpp Outdated
Comment thread tests/queries/0_stateless/04001_join_reorder_through_expression.sql
Comment thread tests/queries/0_stateless/04001_join_reorder_through_expression.sql
Comment thread src/Processors/QueryPlan/Optimizations/joinOrder.cpp
@clickhouse-gh

clickhouse-gh Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

✅ AI verdict: no_change — no significant changes across 38 queries analysed

This PR adds a new join-reordering optimization (query_plan_merge_expression_into_join) that merges Expression steps into JOIN steps and reworks how outer-join ON-clause predicates are pinned during reorder. Both flagged queries are ClickBench Q4 and Q15, which run against a single table with no joins, so the changed code path is never exercised by them. The 18% and 17% improvements are large and consistent in the measurements but cannot plausibly be caused by a join-only optimizer change, so they are downgraded as run-to-run variance rather than real PR effects.

clickbench

⚠️ 2 inconclusive

Flagged queries (2 of 43)
Query Verdict Baseline median (ms) PR median (ms) Change q-value Hint
⚠️ 4 not_sure 263 214 -18.4% <0.0001 aggregation: ClickBench Q4 is single-table, no joins; PR only changes join reordering/expression-merge. Off-path, treat as variance.
⚠️ 15 not_sure 245 204 -16.7% <0.0001 aggregation: ClickBench Q15 is single-table, no joins; PR only touches the join optimizer. Off-path, treat as variance.

q-value = BH-FDR adjusted p; smaller is stronger evidence. MIRAI flags a query when q < fdr_q (default 0.10) — the value the verdict is based on.

tpch_adapted_1_official

🟢 No significant changes

Debug info
  • StressHouse run: 8b257768-7c2d-4e10-a0d0-42674affb3b3
  • MIRAI run: bc0cf70c-6cd1-465a-a489-2a5ebb864a58
  • PR check IDs:
    • clickbench_411604_1782763968
    • clickbench_411621_1782763968
    • tpch_adapted_1_official_411628_1782763968
    • tpch_adapted_1_official_411651_1782763968

/// Include COLUMN Const nodes (like `__join_result_dummy`) in actions_after_join.
/// These are `fromNone` and will be placed in left_dag, ensuring the left pre-join
/// always produces at least one output column (so blocks have correct row count).
if (mapped_it->second->type == ActionsDAG::ActionType::COLUMN)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This makes final COLUMN constants part of actions_after_join, so buildPhysicalJoinImpl treats them as left-side inputs (fromNone goes into left_dag). For RIGHT/FULL joins, unmatched rows are produced by default/null-extending the left side, so a query like SELECT 1 FROM l RIGHT JOIN r ... can return the default/null-extended value for the constant instead of evaluating 1 after the join.

Please keep final constants in the post-join projection/residual DAG and use a guaranteed real input column only for the row-count keeper column.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If we do not put predicate into query_graph.outer_join_conditions it automatically goes into residual filters then

Comment thread tests/queries/0_stateless/04001_join_reorder_through_expression.sql
@vdimir vdimir enabled auto-merge June 30, 2026 16:49
@vdimir vdimir added this pull request to the merge queue Jun 30, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jun 30, 2026
@vdimir vdimir enabled auto-merge July 1, 2026 08:08
@vdimir vdimir disabled auto-merge July 1, 2026 12:46
@vdimir

vdimir commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

@groeneai check out fuzzer and stress test issues and create repro if it's new ones

AST fuzzer (amd_debug, targeted, old_compatibility) FAIL
Logical error: 'Inconsistent AST formatting: the original AST: (STID: 1941-1bfa) FAIL cidb
Stress test (arm_debug) FAIL
Logical error: '!part.empty()' (STID: 2508-2ef2) FAIL cidb
AST fuzzer (amd_msan) FAIL
Logical error: Block structure mismatch in A stream: different columns: (STID: 0993-27f0) FAIL cidb, issue

@groeneai

groeneai commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@vdimir Triaged all three. All are pre-existing trunk failures, none caused by the expression-merge / join-reordering change, so no new repro is needed. Evidence is 30d CIDB (cross-PR + master) + the exact crash sites.

1. STID 1941-1bfa Inconsistent AST formatting (AST fuzzer amd_debug, targeted, old_compatibility)
Known chronic formatter/parser round-trip family, unrelated to join reordering. 30d: 89 hits across 74 distinct PRs + 8 on master, still firing today (last seen 2026-07-02). Prior exact-STID reports: #106850, #106358 (closed).
Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=98533&sha=d44ac9b1a5a8142aeee52375ffbc6863960233ba&name_0=PR&name_1=AST%20fuzzer%20%28amd_debug%2C%20targeted%2C%20old_compatibility%29

2. STID 2508-2ef2 !part.empty() (Stress test arm_debug)
Known latent engine bug, not join-reordering related. ASTIdentifier ctor asserts on an empty name-part when a backquoted dot-terminated identifier (a lambda arg like `__table1.`) is converted QueryTree -> AST while building an error message. Crash site: ASTIdentifier.cpp:48 <- IdentifierNode::toASTImpl <- LambdaNode::toASTImpl. 30d: 2 unrelated branches, 0 on master; the identical stack reproduces on an unrelated branch too. Fix in flight: #108735 (LambdaNode.cpp, which you approved). It is still open, so this run predates it, which is why the assert still fires here.
Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=98533&sha=d44ac9b1a5a8142aeee52375ffbc6863960233ba&name_0=PR&name_1=Stress%20test%20%28arm_debug%29

3. STID 0993-27f0 Block structure mismatch in A stream (AST fuzzer amd_msan)
The issue you already linked, #108142: a chronic UnionStep/IntersectOrExceptStep bug where one branch constant-folds and the common header diverges. 30d: 174 hits across 135 distinct PRs + 13 on master, last seen 2026-07-02. Fix in flight: #107719.
Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=98533&sha=d44ac9b1a5a8142aeee52375ffbc6863960233ba&name_0=PR&name_1=AST%20fuzzer%20%28amd_msan%29

@vdimir vdimir enabled auto-merge July 3, 2026 10:15
@clickhouse-gh

clickhouse-gh Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.60% 85.60% +0.00%
Functions 92.70% 92.70% +0.00%
Branches 77.70% 77.80% +0.10%

Changed lines: Changed C/C++ lines covered: 269/279 (96.42%) · Uncovered code

Full report · Diff report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-improvement Pull request with some product improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants