[SPARK-59579][SQL] Keep the conditional-branch guards after the subexpression elimination shortcut peel - #58868
Draft
LuciferYang wants to merge 2 commits into
Draft
LuciferYang wants to merge 2 commits into
LuciferYang wants to merge 2 commits into
Conversation
…pression elimination shortcut peel `EquivalentExpressions.childrenToRecurse` refuses to descend into the children of an expression whose children must not be evaluated ahead of time: a `CodegenFallback` generates no code for them, a `ConditionalExpression` offers only its `alwaysEvaluatedInputs`, a `HigherOrderFunction` only its always-evaluated arguments, and a `With` binds references that cannot leave its scope. When `spark.sql.subexpressionElimination.skipForShortcutExpr` is on, `skipForShortcut` first peels the leading `And`/`Or` operands to reach the one operand of the chain that is always evaluated. `And`/`Or` are not `ConditionalExpression`s, so the peel walks straight past every case above, and the result was used as `peeled.children` -- recursing into all of the peeled expression's children, conditional branches included. Only the `With` case had a re-check after peeling. So the config that exists to stop a short-circuited operand from being evaluated eagerly made a conditional branch behind it eligible instead. With ANSI mode and both subexpression elimination configs on, `select (case when id = 0 then false else (1 / id + 1 / id) > 0 end) and id >= 0 from range(0, 1, 1, 1)` raised `[DIVIDE_BY_ZERO]`: `1 / id` is repeated inside one branch body, is shared with no other branch, and should run only when that branch runs -- for id = 0 the other branch does. The bypass reaches the `HigherOrderFunction` case too, where the cost would be a failure rather than an early evaluation: a subexpression repeated inside a lambda body becomes a candidate, `supportedExpression` does not stop it because `NamedLambdaVariable` carries no `LAMBDA_VARIABLE` pattern, and generating that candidate at the top of the projection leaves `CodegenContext.getLambdaVar` with no variable to bind. The new unit test pins that such a candidate is no longer collected; no query was found where it changes an answer, so that half closes a hole rather than fixing an observed failure. The always-evaluated operand is now computed once in `updateExprTree`, and both `childrenToRecurse` and `commonChildrenToRecurse` are asked about that operand. That is what removes the `With` re-check, and asking `commonChildrenToRecurse` about it recovers what the peel used to drop: the branch groups of a conditional the peel lands on, whose shared subexpression is safe to evaluate once because the conditional itself always runs. Nothing changes while the config is off, where `skipForShortcut` returns its argument unchanged.
The guards were reached only through `childrenToRecurse`, and the peel inside its fallback case is what walked past them. `commonChildrenToRecurse` is asked about the same operand now, which is what lets a peeled-to conditional's branch groups be read the way a directly met one's are. The test pins each guarded shape met directly and behind an And, an Or chain and a mixed chain, with an `If` shape beside the `CaseWhen` one in the branch-group control, since `If` groups its two values where `CaseWhen` groups conditions and values separately.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
EquivalentExpressions.childrenToRecurserefuses to descend into children that must not be evaluated ahead of time: aCodegenFallbackgenerates no code for them, aConditionalExpressionoffers only itsalwaysEvaluatedInputs, aHigherOrderFunctiononly its always-evaluated arguments, and aWithbinds references that cannot leave its scope.With
spark.sql.subexpressionElimination.skipForShortcutExpron,skipForShortcutpeels the leadingAnd/Oroperands to reach the one operand of the chain that is always evaluated.And/Orare notConditionalExpressions, so that peel walked straight past every case above and the result was used aspeeled.children, recursing into all of the peeled expression's children, conditional branches included. Only theWithcase had a re-check after peeling.The always-evaluated operand is now computed once in
updateExprTree, and bothchildrenToRecurseandcommonChildrenToRecurseare asked about that operand. That removes theWithre-check, and askingcommonChildrenToRecurserecovers what the peel used to drop: the branch groups of a conditional the peel lands on, whose shared subexpression is safe to evaluate once because the conditional itself always runs.One consequence beyond stopping the leak: master reached a peeled-to conditional's branches by recursing into all of its children, which incidentally gave a subexpression shared by every branch a use count of 2, so it was eliminated. That now goes through the branch-group intersection and lands at 1 unless the subexpression also occurs in an always-evaluated position, which is how a conditional met directly has always behaved. So a conditional behind an
And/Orchain can lose an elimination that master made by the route this PR removes.Nothing changes while the config is off, where
skipForShortcutreturns its argument unchanged.Why are the changes needed?
The config exists to stop a short-circuited operand from being evaluated eagerly, and it made a conditional branch behind that operand eligible instead. With ANSI mode and both subexpression elimination configs on,
select (case when id = 0 then false else (1 / id + 1 / id) > 0 end) and id >= 0 from range(0, 1, 1, 1)raised[DIVIDE_BY_ZERO].1 / idis repeated inside a single branch body, is shared with no other branch, and should run only when that branch runs; for id = 0 the other branch does.The bypass reaches the
HigherOrderFunctioncase too, where the cost is a failure rather than an early evaluation: a subexpression repeated inside a lambda body becomes a candidate,supportedExpressiondoes not stop it becauseNamedLambdaVariablecarries noLAMBDA_VARIABLEpattern, and generating that candidate at the top of the projection leavesCodegenContext.getLambdaVarwith no variable to bind. No query was found where that changes an answer, so that half closes a hole rather than fixing an observed failure.Does this PR introduce any user-facing change?
Yes, for a query run with
spark.sql.subexpressionElimination.skipForShortcutExprenabled, which is off by default: the query above returnsfalsewhere it used to raise[DIVIDE_BY_ZERO]. No change with the config off.How was this patch tested?
SubexpressionEliminationSuitepins that no subexpression from a conditional branch, aCodegenFallback's children or a lambda body becomes a candidate, met directly and behind anAnd, anOrchain and a mixed chain, with the branch body's subexpression asserted absent from the recorded set rather than merely uneliminated. Two positive controls sit beside it: the operand the peel lands on still contributes its own duplicates, and a duplicate shared by every branch of anIforCaseWhengroup is still eliminated once, with the use count pinning that it came from the group rather than from recursing into every branch.SQLQuerySuitecovers the query above, in both itsCASE WHENandIFspellings. ANSI mode is pinned on because that is what turns the extra evaluation into a failure; with it off the division returns null, the branch still yields false, and the case would pass either way in the scheduled non-ANSI build.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5