dongjoon-hyun commented on code in PR #59217:
URL: https://github.com/apache/spark/pull/59217#discussion_r4187715493
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// changed anything: `mapExpressions` and `withNewChildren` preserve
reference equality when
// they rewrite nothing, which is what makes this detectable.
if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else
rewrittenProj
+ // A command's own expressions are not evaluated over its children's
rows: most are stored as
+ // metadata or turned into source predicates. A definition hoisted into
a child would leave
+ // them reading a column only that `Project` produces, or leave the
command over a child its
+ // planner does not expect, so it is substituted instead. Analysis lets
no command but
+ // `CreateVariable` hold a nondeterministic expression; one that does
keeps its `With` rather
+ // than be read twice. `CreateVariable` is left out: its default is
evaluated rather than
+ // stored, so a definition there should be memoized, not inlined.
+ case c: Command if !c.isInstanceOf[CreateVariable] &&
+ c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+ case w: With => inlineDefs(w)(_.child.deterministic)
Review Comment:
**This selects definitions by `deterministic` alone, which is weaker than
`isSafeToDuplicate` (L161-196).** `applyForExpression` (L101) uses that check
for the same situation, where there is no plan to pre-evaluate into, and its
scaladoc lists foldable-but-impure cases that `deterministic` accepts:
`aes_encrypt` with a random IV, and a Hive UDF that declares itself
deterministic. For example, in `ALTER TABLE t ADD CONSTRAINT c CHECK
(aes_encrypt(data, k) BETWEEN lo AND hi)` or a DELETE condition of the same
shape, both copies of the definition are kept, and the two comparisons, or
NULLIF's test and its result, read different values. Before this PR the
non-leaf command path hoisted the definition, so it was evaluated once (in the
paths that did not fail). Could this use `isSafeToDuplicate` for multiply
referenced definitions, keeping the `With` otherwise, or at least note this gap
next to the one documented on `canSubstitute` (L232-236)?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// changed anything: `mapExpressions` and `withNewChildren` preserve
reference equality when
// they rewrite nothing, which is what makes this detectable.
if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else
rewrittenProj
+ // A command's own expressions are not evaluated over its children's
rows: most are stored as
+ // metadata or turned into source predicates. A definition hoisted into
a child would leave
+ // them reading a column only that `Project` produces, or leave the
command over a child its
+ // planner does not expect, so it is substituted instead. Analysis lets
no command but
+ // `CreateVariable` hold a nondeterministic expression; one that does
keeps its `With` rather
+ // than be read twice. `CreateVariable` is left out: its default is
evaluated rather than
+ // stored, so a definition there should be memoized, not inlined.
+ case c: Command if !c.isInstanceOf[CreateVariable] &&
Review Comment:
Minor: `!c.isInstanceOf[CreateVariable]` hardcodes whether a command stores
its expressions or evaluates them. `CheckAnalysis` hardcodes `CreateVariable`
the same way (CheckAnalysis.scala:1173). Any other `Command` that evaluates its
own expressions, such as one from an extension or a future scripting command,
now gets every deterministic definition inlined instead of memoized, and
nothing points its author here. Could this be a property of the command, for
example a method on `Command` or a marker trait that both `CheckAnalysis` and
this rule consult, either here or in a follow-up?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala:
##########
@@ -259,6 +259,21 @@ abstract class DeleteFromTableSuiteBase extends
RowLevelOperationSuiteBase {
checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 1)
}
+ test("SPARK-59962: delete with a condition reading a computed value twice") {
+ // BETWEEN reads `id + pk` twice. A group-based delete pushes the
condition down to the scan
+ // and reuses it for runtime group filtering, where a column pre-evaluated
under the write is
+ // not found.
+ createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+ """{ "pk": 1, "id": 1, "dep": "hr" }
+ |{ "pk": 2, "id": 2, "dep": "software" }
+ |{ "pk": 3, "id": 3, "dep": "hr" }
+ |""".stripMargin)
+
+ sql(s"DELETE FROM $tableNameAsString WHERE (id + pk) BETWEEN 2 AND 4")
Review Comment:
Minor: the neighboring tests call `checkDeleteMetrics`, and this one checks
only the remaining rows. The failure this PR fixes (`Could not find
_common_expr_4 in [dep]`) was in the runtime group filter, so if that filter
stopped being injected for this condition, this test would still pass without
covering the fixed path. Could it also call `checkDeleteMetrics`, with the
copied-row count that group filtering produces, so that the filter is pinned?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// changed anything: `mapExpressions` and `withNewChildren` preserve
reference equality when
// they rewrite nothing, which is what makes this detectable.
if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else
rewrittenProj
+ // A command's own expressions are not evaluated over its children's
rows: most are stored as
+ // metadata or turned into source predicates. A definition hoisted into
a child would leave
+ // them reading a column only that `Project` produces, or leave the
command over a child its
+ // planner does not expect, so it is substituted instead. Analysis lets
no command but
+ // `CreateVariable` hold a nondeterministic expression; one that does
keeps its `With` rather
+ // than be read twice. `CreateVariable` is left out: its default is
evaluated rather than
+ // stored, so a definition there should be memoized, not inlined.
+ case c: Command if !c.isInstanceOf[CreateVariable] &&
+ c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+ case w: With => inlineDefs(w)(_.child.deterministic)
Review Comment:
Nit: this keeps a nondeterministic definition even when it is referenced
only once. `canSubstitute` (L242) inlines any definition with at most one
reference, since that cannot change semantics. A kept `With` cannot be
translated into a V2 predicate and hides the expression from later folding
(L273-278). Before this PR, a leaf command inlined such a definition through
the `childPlanIndex == -1` path. Analysis keeps nondeterministic expressions
out of these commands today, so this is about consistency more than a reachable
bug. Could the selector be `d => !multiplyReferenced.contains(d.id) ||
d.child.deterministic`, using `multiplyReferencedIds(w.child, w.defs)`?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala:
##########
@@ -486,6 +486,48 @@ class RewriteWithExpressionSuite extends PlanTest {
)
}
+ test("SPARK-59962: a With in a command's condition is inlined instead of
hoisted") {
+ val a = testRelation.output.head
+ // The condition is translated into source predicates, not evaluated over
the child's rows, so a
+ // `Project` under the command would sit where the planner expects the
table.
+ val condition = With(a + a) { case Seq(ref) => ref < 10 && ref > 0 }
+ comparePlans(
+ Optimizer.execute(DeleteFromTable(testRelation, condition)),
+ DeleteFromTable(testRelation, (a + a) < 10 && (a + a) > 0))
+
+ // Nor is one in a conditional branch kept: a `With` translates to no
source predicate.
+ val inBranch = If(a > 0, With(a + a) { case Seq(ref) => ref < 10 && ref >
0 }, Literal(false))
+ comparePlans(
+ Optimizer.execute(DeleteFromTable(testRelation, inBranch)),
+ DeleteFromTable(testRelation, If(a > 0, (a + a) < 10 && (a + a) > 0,
Literal(false))))
+ }
+
+ test("SPARK-59962: a nondeterministic definition in a command keeps its
With") {
+ // Analysis keeps nondeterministic expressions out of every command but
`CreateVariable`, so
+ // this only pins that the rule would not read such a definition twice.
+ val a = testRelation.output.head
+ val delete = DeleteFromTable(testRelation, With(Rand(Literal(0L)) + a) {
case Seq(ref) =>
+ ref < 10 && ref > 0
+ })
+ assert(Optimizer.execute(delete) == delete)
+ }
+
+ test("SPARK-59962: a definition in a DECLARE VARIABLE default is not inlined
like a command's") {
+ // The default is evaluated rather than stored, so a definition there that
is worth memoizing
+ // must not be substituted at each of its references the way a command's
is. Until SPARK-59954
+ // keeps it in the default, the generic case pre-evaluates it under the
variable names instead;
+ // either way there is at most one copy.
+ val ident = ResolvedIdentifier(FakeSystemCatalog,
Identifier.of(Array("session"), "v"))
+ // Deterministic but not cheap: the command case would inline it.
+ val plusOne = ScalaUDF((i: Int) => i + 1, IntegerType, Seq(Literal(1)),
udfName = Some("f"))
+ val declare = CreateVariable(
+ Seq(ident), DefaultValueExpression(With(plusOne) { case Seq(ref) => ref
* ref }, "v"),
+ replace = true)
+ val copies = Optimizer.execute(declare).expressions
+ .map(_.collect { case u: ScalaUDF => u }.size).sum
+ assert(copies <= 1, s"the definition was substituted at $copies
references")
Review Comment:
Minor: this counts copies only in `Optimizer.execute(declare).expressions`,
which are the `CreateVariable`'s own expressions. On this branch the generic
case hoists the UDF into `Project(..., ResolvedIdentifier)`, so the default
holds only `_common_expr_0` and `copies` is 0. The test would also pass if the
definition were dropped or folded away, so it catches only the 2-copy
regression that HyukjinKwon's point asked about. Could it count over the whole
plan, e.g. `Optimizer.execute(declare).collectWithSubqueries { case p =>
p.expressions }.flatten`, and assert `== 1`?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// changed anything: `mapExpressions` and `withNewChildren` preserve
reference equality when
// they rewrite nothing, which is what makes this detectable.
if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else
rewrittenProj
+ // A command's own expressions are not evaluated over its children's
rows: most are stored as
Review Comment:
Nit: the object scaladoc (L31-40) still says the rule either adds a
`Project` to pre-evaluate the common expressions or inlines them if they are
cheap. With this case there is a third outcome: in a command, every
deterministic definition is inlined whatever it costs, and a nondeterministic
one stays a `With`. Could the scaladoc mention it?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// changed anything: `mapExpressions` and `withNewChildren` preserve
reference equality when
// they rewrite nothing, which is what makes this detectable.
if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else
rewrittenProj
+ // A command's own expressions are not evaluated over its children's
rows: most are stored as
+ // metadata or turned into source predicates. A definition hoisted into
a child would leave
+ // them reading a column only that `Project` produces, or leave the
command over a child its
+ // planner does not expect, so it is substituted instead. Analysis lets
no command but
+ // `CreateVariable` hold a nondeterministic expression; one that does
keeps its `With` rather
+ // than be read twice. `CreateVariable` is left out: its default is
evaluated rather than
+ // stored, so a definition there should be memoized, not inlined.
+ case c: Command if !c.isInstanceOf[CreateVariable] &&
+ c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
Review Comment:
**The condition of a row-level command is not only metadata: it is also
evaluated per row.** For a group-based or delta-based DELETE,
`RowLevelOperationRuntimeGroupFiltering`
(RowLevelOperationRuntimeGroupFiltering.scala:55-65) wraps `cond` in a `Filter`
over the table scan to find the groups to rewrite. Inlining every deterministic
definition regardless of cost means that `DELETE FROM t WHERE expensive_udf(id)
BETWEEN 2 AND 4` now evaluates `expensive_udf` twice per scanned row there, and
a scalar subquery in the definition is copied twice with the same exprId.
`canSubstitute` (L242) avoids this with `CollapseProject.isCheap`. Could the
trade-off be noted in the comment above, or a non-cheap definition be kept as a
`With` in that injected filter?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]