dongjoon-hyun commented on code in PR #59146:
URL: https://github.com/apache/spark/pull/59146#discussion_r4140449874
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
+ val source = ColumnarCacheSerializationSource()
+ val join = SortMergeJoinExec(
+ Seq(Literal(1)), Seq(Literal(1)), Inner, None,
+ ColumnarToRowExec(source), LocalTableScanExec(Nil, Nil, None))
+ val (ctx, code) = WholeStageCodegenExec(join)(0).doCodeGen()
+ assert(ctx.references.exists(_.asInstanceOf[AnyRef] eq join))
+
+ val rdd = source.executeColumnar()
+ assert(source.executeColumnar() eq rdd)
+ val factory = new WholeStageCodegenEvaluatorFactory(
+ Right(code), join.longMetric("numOutputRows"), ctx.references.toArray)
+ val serializer = SparkEnv.get.closureSerializer.newInstance()
+ // The cached RDD holds non-serializable driver state, but the generated
evaluator does not
+ // need it. Input iterators are passed separately when the evaluator runs.
+
serializer.deserialize[WholeStageCodegenEvaluatorFactory](serializer.serialize(factory))
+ val readback =
serializer.deserialize[SortMergeJoinExec](serializer.serialize(join))
+ readback.cleanupResources()
Review Comment:
These two lines can never fail on their own:
- `join` is already in `factory.references` (line 51), so line 60 serializes
the same object graph. Without the fix, line 60 fails first.
- No node in this tree (`SortMergeJoinExec`, `ColumnarToRowExec`,
`LocalTableScanExec`, `ColumnarCacheSerializationSource`) overrides
`cleanupResources()`, so it only recurses into the children and never touches
`executeColumnarRDD`.
Could you remove them, along with the "deserialized-plan cleanup" claim in
the PR description?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala:
##########
@@ -218,6 +218,7 @@ abstract class SparkPlan extends QueryPlan[SparkPlan] with
Logging with Serializ
executeBroadcastBcast.get.asInstanceOf[broadcast.Broadcast[T]]
}
+ @transient
private val executeColumnarRDD = LazyTry {
doExecuteColumnar()
Review Comment:
`AQEShuffleReadExec` is a case where this change has no effect at all. Its
`doExecuteColumnar()` (and `doExecute()`) returns `shuffleRDD`, which is a
non-transient `private lazy val`:
https://github.com/apache/spark/blob/a026a0be4c88e7ace59d1da7c18ab73b0d81edde/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEShuffleReadExec.scala#L268-L284
So the very object cached in `executeColumnarRDD` is still serialized
through `shuffleRDD` when a downstream stage's `SortExec`, `HashAggregateExec`,
`SortMergeJoinExec`, or `ShuffledHashJoinExec` captures a subtree that reaches
an earlier stage's `AQEShuffleReadExec` through the non-transient
`ShuffleQueryStageExec.plan`. For the columnar path this only happens with
plugin `ShuffleExchangeLike` implementations, but the row path has bypassed the
`@transient executeRDD` in the same way since SPARK-48195. `@transient private
lazy val shuffleRDD` (like `partitionDataSizes` and `metrics` in the same
class) would close it.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
+ val source = ColumnarCacheSerializationSource()
+ val join = SortMergeJoinExec(
+ Seq(Literal(1)), Seq(Literal(1)), Inner, None,
+ ColumnarToRowExec(source), LocalTableScanExec(Nil, Nil, None))
Review Comment:
This plan shape is a same-stage case where the fix does not change the job
outcome. If it were planned and executed, this child would become
`InputAdapter(WholeStageCodegenExec(ColumnarToRowExec(InputAdapter(source))))`,
and `ColumnarToRowExec.inputRDDs()` returns the cached RDD itself. So the
cached RDD is a parent of the stage RDD and is serialized in the same task
binary anyway:
- If the cached RDD is not serializable and has partitions, the job still
fails with `Task not serializable`, with or without `@transient`.
- If it is serializable, the plan reference costs no extra bytes because of
Java serialization back-references.
The test passes because it serializes the factory in isolation, without the
stage RDD lineage. The fix really matters for cross-stage capture, e.g., a
cached DataFrame (columnar `InMemoryTableScanExec`) behind a shuffle under a
`SortMergeJoinExec` or `HashAggregateExec`. Could you add a test with such a
query that asserts the upstream RDD is not serialized into the downstream stage
closure? (A Parquet variant would still fail because of
`FileSourceScanExec.inputRDD`.)
In addition, the comment at lines 58-59 ("Input iterators are passed
separately") is misleading because those iterators are computed from exactly
this RDD's lineage. The chain in the PR description also skips a hop: it is the
`executeColumnarRDD` of `ColumnarToRowExec.child` (the source), not of
`ColumnarToRowExec`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala:
##########
@@ -218,6 +218,7 @@ abstract class SparkPlan extends QueryPlan[SparkPlan] with
Logging with Serializ
executeBroadcastBcast.get.asInstanceOf[broadcast.Broadcast[T]]
}
+ @transient
private val executeColumnarRDD = LazyTry {
Review Comment:
This makes `executeColumnarRDD` transient, but for Spark's own columnar
scans the same RDD chain is still reachable from the plan through non-transient
subclass caches:
- `FileSourceScanExec.inputRDD` and `maybeCoalesceInputRDD`.
`doExecuteColumnar()` is `maybeCoalesceInputRDD.mapPartitionsInternal { ... }`.
- `BatchScanExec.inputRDD` and the streaming DSv2 scans
(`MicroBatchScanExec`, `ContinuousScanExec`, `RealTimeStreamScanExec`).
`doExecuteColumnar()` is `inputRDD.map { ... }`.
https://github.com/apache/spark/blob/a026a0be4c88e7ace59d1da7c18ab73b0d81edde/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala#L752
For example, in `spark.read.parquet(path).groupBy("k").count()` with the
vectorized reader, the final `HashAggregateExec` still drags the `FileScanRDD`
into the reduce-stage task binary through `ShuffleQueryStageExec.plan` (or
`ShuffleExchangeExec.child` without AQE). This PR removes only the thin wrapper
RDD there. If `ColumnarCacheSerializationSource` cached its RDD in a `lazy val
inputRDD` like the real scans do, the new test would still fail after this fix.
These fields are only read on the driver, so marking them `@transient` like
`ShuffleExchangeExec.inputRDD` and `LocalTableScanExec.rdd` should be safe.
Could you include them in this PR, or narrow the PR title and description?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala:
##########
@@ -218,6 +218,7 @@ abstract class SparkPlan extends QueryPlan[SparkPlan] with
Logging with Serializ
executeBroadcastBcast.get.asInstanceOf[broadcast.Broadcast[T]]
}
+ @transient
Review Comment:
This is a regression from SPARK-48195
([d2e8c1c](https://github.com/apache/spark/commit/d2e8c1cb60e34a1c7e92374c07d682aa5ca79145),
#48037), which added `@transient` only to `executeRDD`. It was first released
in [v4.0.0](https://github.com/apache/spark/releases/tag/v4.0.0) (2025-05-19),
so `branch-4.0` through `branch-4.3` have the same issue. Could you mention
this in the PR description so that we can consider backporting? The description
also still says "Draft pending full source-build validation."
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala:
##########
@@ -218,6 +218,7 @@ abstract class SparkPlan extends QueryPlan[SparkPlan] with
Logging with Serializ
executeBroadcastBcast.get.asInstanceOf[broadcast.Broadcast[T]]
Review Comment:
SPARK-48195 introduced three `LazyTry` caches, and with this PR
`executeBroadcastBcast` becomes the only non-transient one. So every plan node
serialized through codegen `plan` references still carries an unused `LazyTry`
with its initializer lambda, and materialized broadcast nodes carry
`Success(TorrentBroadcast)`. This is harmless in practice because only the
broadcast handle is serialized. However, a deserialized plan can never reach it
either, since `executeQuery` dereferences the `@transient session` first.
Adding `@transient` to `executeBroadcastBcast` would make the three caches
consistent. WDYT?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -268,6 +290,22 @@ case class ColumnarOp(child: SparkPlan) extends
UnaryExecNode {
copy(child = newChild)
}
+case class ColumnarCacheSerializationSource() extends LeafExecNode {
+ override val output: Seq[Attribute] = Seq(AttributeReference("id",
IntegerType)())
+ override val supportsColumnar: Boolean = true
+
+ override protected def doExecuteColumnar(): RDD[ColumnarBatch] = {
+ new RDD[ColumnarBatch](sparkContext, Nil) {
+ val driverOnlyState: AnyRef = new Object
+ override def compute(split: Partition, context: TaskContext):
Iterator[ColumnarBatch] =
+ Iterator.empty
+ override protected def getPartitions: Array[Partition] = Array.empty
+ }
Review Comment:
nit: This reimplements `org.apache.spark.rdd.EmptyRDD`, which is
`private[spark]` and already used in `sql/core`. The following keeps the
non-serializable sentinel, and the `Partition` and `TaskContext` imports can be
removed.
```scala
new EmptyRDD[ColumnarBatch](sparkContext) {
val driverOnlyState: AnyRef = new Object
}
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -268,6 +290,22 @@ case class ColumnarOp(child: SparkPlan) extends
UnaryExecNode {
copy(child = newChild)
}
+case class ColumnarCacheSerializationSource() extends LeafExecNode {
Review Comment:
nit: With an empty parameter list, `TreeNode.makeCopy` (`allowEmptyArgs =
false`) cannot find a constructor. So canonicalizing this node or any of its
ancestors (`canonicalized`, `sameResult`) throws `INTERNAL_ERROR: Couldn't find
a valid constructor`. The current test doesn't canonicalize, but it would break
as soon as someone adds a SPARK-23731-style check. In addition, all instances
are equal to each other while their `output` expression IDs differ. `case class
ColumnarCacheSerializationSource(output: Seq[Attribute])` would avoid both.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
Review Comment:
nit: Could you add the JIRA ID prefix like the other regression tests in
this suite?
```suggestion
test("SPARK-59884: columnar execution cache is not captured by whole-stage
codegen references") {
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
+ val source = ColumnarCacheSerializationSource()
+ val join = SortMergeJoinExec(
+ Seq(Literal(1)), Seq(Literal(1)), Inner, None,
+ ColumnarToRowExec(source), LocalTableScanExec(Nil, Nil, None))
+ val (ctx, code) = WholeStageCodegenExec(join)(0).doCodeGen()
+ assert(ctx.references.exists(_.asInstanceOf[AnyRef] eq join))
Review Comment:
This assertion points at the root cause, which is one level above the
patched field. `SortMergeJoinExec`, `SortExec`, `HashAggregateExec`, and
`ShuffledHashJoinExec` put `this` into the codegen references
(`ctx.addReferenceObj("plan", this)`). As a result, the whole subtree is
serialized into each task, and it crosses stage boundaries through the
non-transient `QueryStageExec.plan`, `QueryStageExec._canonicalized`, and
`ShuffleExchangeExec.child`. Downstream tasks therefore also deserialize all
upstream stage plans and register their `SQLMetric`s. The non-codegen paths
(the `SortExec.doExecute` closure and `SortMergeJoinEvaluatorFactory(left,
right)`) capture the subtree too.
A field-level `@transient` fixes one symptom. It may be worth a follow-up
JIRA to (1) make all execution caches in `SparkPlan` and its subclasses
transient consistently, and (2) avoid serializing plans across stage
boundaries, e.g., by keeping only `output`.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
+ val source = ColumnarCacheSerializationSource()
+ val join = SortMergeJoinExec(
+ Seq(Literal(1)), Seq(Literal(1)), Inner, None,
+ ColumnarToRowExec(source), LocalTableScanExec(Nil, Nil, None))
+ val (ctx, code) = WholeStageCodegenExec(join)(0).doCodeGen()
+ assert(ctx.references.exists(_.asInstanceOf[AnyRef] eq join))
+
+ val rdd = source.executeColumnar()
+ assert(source.executeColumnar() eq rdd)
+ val factory = new WholeStageCodegenEvaluatorFactory(
+ Right(code), join.longMetric("numOutputRows"), ctx.references.toArray)
Review Comment:
nit: The second parameter of `WholeStageCodegenEvaluatorFactory` is
`durationMs`, and production passes `longMetric("pipelineTime")` of
`WholeStageCodegenExec`. This passes the join's `numOutputRows` instead. It's
harmless here because no evaluator is created, but an evaluation would add the
pipeline time to the row count. How about keeping `val wscg =
WholeStageCodegenExec(join)(0)` and passing `wscg.longMetric("pipelineTime")`?
This is unnecessary if the test is simplified as suggested above.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala:
##########
@@ -40,6 +42,26 @@ import org.apache.spark.util.ThreadUtils
class SparkPlanSuite extends SharedSparkSession {
+ test("columnar execution cache is not captured by whole-stage codegen
references") {
+ val source = ColumnarCacheSerializationSource()
+ val join = SortMergeJoinExec(
Review Comment:
nit: The SMJ, `doCodeGen()`, and hand-built factory scaffolding isn't needed
for the property under test, and line 51 couples the test to SMJ codegen
internals. If SMJ stops referencing `this` in the future, this test will break
even though the property improves. A direct round trip fails in the same way
without the fix (`LazyTry.tryT = Success(rdd)` -> `driverOnlyState`), like the
SPARK-23731 and SPARK-27418 tests in this suite:
```scala
val source = ColumnarCacheSerializationSource()
source.executeColumnar() // Materialize the cached columnar RDD.
val serializer = SparkEnv.get.closureSerializer.newInstance()
// Parent plans can be captured by task closures, e.g.,
SortMergeJoinExec adds itself to
// the whole-stage codegen references.
serializer.deserialize[SparkPlan](serializer.serialize(ColumnarToRowExec(source)))
```
--
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]