HyukjinKwon commented on code in PR #59283:
URL: https://github.com/apache/spark/pull/59283#discussion_r4277519373
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/EquivalentExpressions.scala:
##########
@@ -173,6 +172,29 @@ class EquivalentExpressions(
case other => other.children
}
+ private def alwaysEvaluatedDefinitions(withExpr: With): Seq[Expression] = {
+ val definitions = withExpr.defs.map(definition => definition.id ->
definition).toMap
+ val required = mutable.HashSet.empty[CommonExpressionId]
+ def collect(expression: Expression, shadowed: Set[CommonExpressionId]):
Unit = {
+ val alwaysEvaluated = skipForShortcut(expression)
+ if (alwaysEvaluated.containsPattern(COMMON_EXPR_REF)) {
+ alwaysEvaluated match {
+ case ref: CommonExpressionRef if !shadowed.contains(ref.id) =>
+ definitions.get(ref.id).foreach { definition =>
+ if (required.add(ref.id)) collect(definition.child, Set.empty)
+ }
+ case nested: With =>
+ val innerIds = shadowed ++ nested.defs.map(_.id)
+ childrenToRecurse(nested).foreach(collect(_, innerIds))
Review Comment:
**Non-blocking (P2):** `childrenToRecurse(nested)` returns `nested.child +:
alwaysEvaluatedDefinitions(nested)`, and computing those definitions already
walks `nested.child` once. The `foreach(collect(_, innerIds))` here then walks
`nested.child` again. Nothing is memoized, so each nested `With` doubles the
work below it. `updateExprTree` also calls `childrenToRecurse` at every `With`
it visits, which repeats the walk for each inner level.
The new lowering produces exactly these chains. For an input that is not
`struct(...)`, each level of a dotted path becomes
`With(GetStructField(outerRef, i)) { UpdateFieldsExpression(innerRef, ...) }`
inside the parent level's updated values. When the path is non-nullable,
`UpdateFieldsExpression` lists its updated values as always evaluated, so
`collect` reaches every level. Take a schema whose nested struct fields are
declared non-nullable many levels deep, from an explicit schema or NOT NULL
source columns, and call `col("s").withField("l1.l2.....lN.x", lit(1))` or
`dropFields` with that path. Each CSE pass over the projection then does work
that roughly doubles with every extra level. At about 20 levels that is on the
order of a million walks of the remaining body, on the driver for whole-stage
codegen and wherever projections are built. Results stay correct, but planning
and projection setup become effectively unbounded. The doubling reaches only as
deep as the path stays no
n-nullable, because a nullable level's updated values are conditional. Flat
updates and `struct(...)` inputs build no nested `With`. The merge target
stopped at every `With`, and its lowering had none, so its CSE walk was linear.
The PR's linearity tests count plan nodes and merge visits, not CSE work, so
none of them catches this.
Handling the nested `With` in one pass would fix it. Stop if
`refUnderCodegenFallback` is set. Otherwise walk `nested.child` once with its
definition ids in scope, and the first time an always-evaluated reference
reaches one of those definitions, walk that definition's child in the enclosing
scope so its outer references still mark outer definitions as required.
Memoizing `alwaysEvaluatedDefinitions` per `With` instance within one traversal
would also work.
**Verification:**
- **Regression:** Run CSE over a nested With chain modeled on nested-path
withField lowering over a non-nullable struct, deep enough (for example 30
levels) that per-level doubling would be infeasible. It must complete with
bounded work that grows with depth at most polynomially, and still report the
expected common subexpressions.
- **Compatibility:** The existing With subexpression-elimination cases
(nested scopes, guards, short-circuit peel, fallback, higher-order lambdas,
shadowing, lexical binding, and the SPARK-58902 unbound-reference check) yield
the same common subexpressions and the same rejected candidates.
- **Behavior:** Nested-path withField/dropFields queries still evaluate a
shared struct input and a shared always-evaluated value once per row on the
interpreted, codegen-only and whole-stage paths, with correct results.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeCreator.scala:
##########
@@ -806,35 +989,151 @@ case class UpdateFields(structExpr: Expression,
fieldOps: Seq[StructFieldsOperat
override def prettyName: String = "update_fields"
- private lazy val newFieldExprs: Seq[(StructField, Expression)] = {
- def getFieldExpr(i: Int): Expression = structExpr match {
- case c: CreateNamedStruct => c.valExprs(i)
- case _ => GetStructField(structExpr, i)
+ private[sql] def sqlString(
+ render: Expression => String,
+ quoteField: String => String): String = {
+ def updateSql(inputSql: String, operations: Seq[StructFieldsOperation]):
String = {
+ val operationsSql = operations.map {
+ case nested: UpdateNestedField =>
+ val fieldSql = if (structExpr.resolved) {
+ s"$inputSql.${quoteField(nested.name)}"
+ } else {
+ s"$inputSql[${render(Literal(nested.name))}]"
+ }
+ s"WithField(${updateSql(fieldSql, nested.fieldOps)})"
+ case operation => render(operation)
+ }
+ s"$prettyName(${(inputSql +: operationsSql).mkString(", ")})"
}
- val fieldsWithIndex =
structExpr.dataType.asInstanceOf[StructType].fields.zipWithIndex
- val existingFieldExprs: Seq[(StructField, Expression)] =
- fieldsWithIndex.map { case (field, i) => (field, getFieldExpr(i))
}.toImmutableArraySeq
- fieldOps.foldLeft(existingFieldExprs)((exprs, op) => op(exprs))
+ updateSql(render(structExpr), fieldOps)
}
- private lazy val newFields: Seq[StructField] = newFieldExprs.map(_._1)
+ override def sql: String = sqlString(_.sql, quoteIdentifier)
- lazy val newExprs: Seq[Expression] = newFieldExprs.map(_._2)
+ private lazy val newFieldSources: Seq[(StructField,
UpdateFields.FieldSource)] =
+ UpdateFields.fieldSources(structExpr, fieldOps)
- lazy val evalExpr: Expression = {
- val createNamedStructExpr = CreateNamedStruct(newFieldExprs.flatMap {
- case (field, expr) => Seq(Literal(field.name), expr)
- })
+ private lazy val newFields: Seq[StructField] = newFieldSources.map(_._1)
- if (structExpr.nullable) {
- If(IsNull(structExpr), Literal(null, dataType), createNamedStructExpr)
- } else {
- createNamedStructExpr
+ /** Returns the expression for one output field without constructing the
complete struct. */
+ def newExpr(ordinal: Int): Expression = newFieldSources(ordinal)._2 match {
+ case UpdateFields.Original(sourceOrdinal) => getFieldExpr(sourceOrdinal)
+ case UpdateFields.Updated(expr) => expr
+ }
+
+ private def getFieldExpr(ordinal: Int): Expression = structExpr match {
+ case c: CreateNamedStruct => c.valExprs(ordinal)
+ case _ => GetStructField(structExpr, ordinal)
+ }
+
+ lazy val evalExpr: Expression = {
+ def buildExpression(structRef: Expression): Expression = {
+ val fieldSources = UpdateFields.fieldSources(structRef, fieldOps)
+ val updatedValues = ArrayBuffer.empty[Expression]
+ val outputOrdinals = fieldSources.map {
+ case (_, UpdateFields.Original(ordinal)) => ordinal
+ case (_, UpdateFields.Updated(expr)) =>
+ updatedValues += expr
+ -1
+ }
+ UpdateFieldsExpression(structRef, updatedValues.toSeq, outputOrdinals,
newFields)
+ }
+ structExpr match {
+ case namedStruct: CreateNamedStruct =>
+ val values = namedStruct.valExprs.toArray
+ CreateNamedStruct(newFieldSources.flatMap { case (field, source) =>
+ val value = source match {
+ case UpdateFields.Original(ordinal) => values(ordinal)
+ case UpdateFields.Updated(expr) => expr
+ }
+ Seq(Literal(field.name), value)
+ })
+ case _ if fieldOps.exists(_.isInstanceOf[UpdateNestedField]) =>
+ With(structExpr) { case Seq(structRef) => buildExpression(structRef) }
Review Comment:
Thanks, verified on the current head. When CSE reaches a With, it now also
walks the definitions the body always evaluates, so the struct input of a
nested-path update can be shared with other uses in the same projection.
supportedExpression still rejects candidates that hold unbound references, and
guarded, unused, CodegenFallback, lambda and shadowed definitions are not
hoisted. The new withField/dropFields test checks that a shared struct input is
evaluated once per row on the interpreted, codegen-only and whole-stage paths.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4274818970","thread_id":"inline:4274818970","verdict_sha256":"da4e342b83744e2a784cb95fd720aa39782cab886b1272d5195846b31f427d38"}
-->
##########
sql/core/src/test/scala/org/apache/spark/sql/ColumnExpressionSuite.scala:
##########
@@ -445,6 +446,428 @@ class ColumnExpressionSuite extends SharedSparkSession {
SQLConf.CODEGEN_FACTORY_MODE.key -> "CODEGEN_ONLY")(f)
}
+ test("withField should evaluate a nondeterministic struct expression once") {
+ onEachEvalPath {
+ val counter = new java.util.concurrent.atomic.AtomicLong()
+ val nextStruct = udf(() => {
+ val value = counter.getAndIncrement()
+ (value, value)
+ }).asNondeterministic()
+ val structExpr = nextStruct()
+ val df = spark.range(0, 10, 1, 1)
+ df.select(structExpr.withField("copy", structExpr)).collect().foreach {
row =>
+ val result = row.getStruct(0)
+ val copy = result.getStruct(2)
+ assert(result.getLong(0) == result.getLong(1))
+ assert(copy.getLong(0) == copy.getLong(1))
+ assert(result.getLong(0) != copy.getLong(0))
+ }
+ }
+ }
+
+ test("withField should evaluate a reused nondeterministic value separately")
{
+ onEachEvalPath {
+ val counter = new java.util.concurrent.atomic.AtomicLong()
+ val nextStruct = udf(() => {
+ val value = counter.getAndIncrement()
+ (value, value)
+ }).asNondeterministic()
+ val structExpr = nextStruct().expr
+ val updated = Column(UpdateFields(structExpr,
+ Seq(WithField("copy", GetStructField(structExpr, 0)))))
+ val df = spark.range(0, 10, 1, 1)
+ df.select(updated).collect().foreach { row =>
+ val result = row.getStruct(0)
+ assert(result.getLong(0) == result.getLong(1))
+ assert(result.getLong(0) != result.getLong(2))
+ }
+ }
+ }
+
+ test("withField should evaluate a reused nested update separately") {
+ onEachEvalPath {
+ val counter = new java.util.concurrent.atomic.AtomicLong()
+ val nextStruct = udf(() => {
+ val value = counter.getAndIncrement()
+ (value, value)
+ }).asNondeterministic()
+ val structExpr = nextStruct().expr
+ val nestedUpdate = UpdateFields(structExpr, Seq(WithField("extra",
Literal(1))))
+ val updated = Column(UpdateFields(structExpr,
+ Seq(WithField("copy", GetStructField(nestedUpdate, 0)))))
+ val df = spark.range(0, 10, 1, 1)
+ df.select(updated).collect().foreach { row =>
+ val result = row.getStruct(0)
+ assert(result.getLong(0) == result.getLong(1))
+ assert(result.getLong(0) != result.getLong(2))
+ }
+ }
+ }
+
+ test("field updates should preserve shared value subexpression elimination")
{
+ withSQLConf(SQLConf.SUBEXPRESSION_ELIMINATION_ENABLED.key -> "true",
+ SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true") {
+ for {
+ nested <- Seq(false, true)
+ external <- Seq(false, true)
+ opaque <- Seq(false, true)
+ } {
+ val calls = spark.sparkContext.longAccumulator
+ val sourceCalls = spark.sparkContext.longAccumulator
+ val value = udf((id: Long) => {
+ calls.add(1)
+ id + 1
+ }).apply($"id")
+ val source = if (opaque) {
+ udf((id: Long) => {
+ sourceCalls.add(1)
+ ((id, id), id)
+ }).asNonNullable().asNondeterministic()($"id")
+ } else {
+ struct(struct($"id", $"id").as("_1"), $"id".as("_2"))
+ }
+ val prefix = if (nested) "_1." else ""
+ val updated = source.withField(s"${prefix}first", value)
+ .withField(s"${prefix}second", value)
+ val columns = if (external) Seq(updated, value) else Seq(updated)
+ val rows = spark.range(0, 10, 1, 1).select(columns: _*).collect()
+ rows.zipWithIndex.foreach { case (row, index) =>
+ val result = if (nested) row.getStruct(0).getStruct(0) else
row.getStruct(0)
+ assert(result.getLong(2) == index + 1L)
+ assert(result.getLong(3) == index + 1L)
+ if (external) assert(row.getLong(1) == index + 1L)
+ }
+ val expectedCalls = if (nested && opaque) 10 * (if (external) 3 else
2) else 10
Review Comment:
Thanks, verified. The new test uses a non-nullable nested parent, so the
inner update evaluates both values unconditionally. It shares one value across
two nested updates and a separate column, and checks the results and
once-per-row evaluation on all three evaluation paths. Without the With-body
traversal, the shared value would be computed three times per row, so the test
now catches that regression.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4274818989","thread_id":"inline:4274818989","verdict_sha256":"da4e342b83744e2a784cb95fd720aa39782cab886b1272d5195846b31f427d38"}
-->
--
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]