This is an automated email from the ASF dual-hosted git repository.
uros-b pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 4b4c443f4df9 [SPARK-56032][SQL][FOLLOWUP] Skip FilterExec CSE codegen
when every common subexpression is cheap
4b4c443f4df9 is described below
commit 4b4c443f4df936291f4f9949a140a103830c42c7
Author: Wenchen Fan <[email protected]>
AuthorDate: Sun Jun 21 16:34:10 2026 +0200
[SPARK-56032][SQL][FOLLOWUP] Skip FilterExec CSE codegen when every common
subexpression is cheap
### What changes were proposed in this pull request?
A follow-up of #54862 (which introduced subexpression elimination (CSE) in
`FilterExec`
whole-stage codegen) and #56209 (which gated the CSE path on whether
`otherPreds` contain a
common subexpression).
The #56209 gate takes the CSE path whenever
`otherPredsEquivalentExpressions.getCommonSubexpressions`
is non-empty. That correctly excludes the no-shared-subexpression case, but
it still takes the CSE
path when the only shared subexpressions are **cheap**. The realistic case
is a shared non-leaf slot
read such as a struct field access: `s.x > 5 AND s.x < 100` shares
`GetStructField(s, x)`. Caching
such a cheap read gains nothing -- the non-CSE path already loads each
column lazily into a variable
on demand, just before the predicate that needs it -- so taking the CSE
path only adds the eager
`inputVarsEvalCode` prologue, which evaluates **every** column referenced
by `otherPreds` at the top
of the per-row loop and defeats the short-circuiting the non-CSE path gets
for free.
This PR requires a **non-cheap** common subexpression (per
`CollapseProject.isCheap`) before taking
the CSE path. Filters with a genuine repeated computation (e.g. `a + b`)
are unaffected and still
benefit from CSE.
`CollapseProject.isCheap` is the canonical "cheap to recompute" predicate
(attributes, foldables,
`Alias`/`ExtractValue` of cheap children). The gate runs on predicates
already bound for codegen, so
this PR also teaches `isCheap` that `BoundReference` -- the codegen-bound
form of an `Attribute`, an
equally cheap slot read -- is cheap. Without that,
`GetStructField(BoundReference, ...)` would not be
recognized as cheap (an `ExtractValue` is only cheap when its child is),
and the struct-field filter
above would still wrongly take the CSE path. Reusing `isCheap` here also
lets the gate share the
single `EquivalentExpressions` analysis it already builds for the CSE
codegen rather than
re-analyzing the predicates.
Note that bare columns never reach this gate in the first place:
`EquivalentExpressions` skips
`LeafExpression`s (`BoundReference`/`Attribute`), and
`splitConjunctivePredicates` feeds each
conjunct to a separate `addExprTree` call, so a column repeated across
conjuncts is never recorded
as a common subexpression. The cheap case this gate filters out is
therefore the shared **non-leaf**,
with struct field access as the realistic example.
### Why are the changes needed?
When the only common subexpressions are cheap, the CSE path's eager
prologue is pure overhead: it
decodes every referenced column up front, including columns needed only by
a later predicate that an
earlier cheaper predicate would have short-circuited past. For `s.x > 5 AND
s.x < 100`, the #56209
gate takes the CSE path solely because `GetStructField(s, x)` is shared,
then evaluates the struct
field for every row at the top of the loop instead of lazily after the
first comparison fails.
Requiring a non-cheap common subexpression keeps such filters on the lazy,
short-circuiting path
while preserving CSE for genuine repeated computation -- completing the
intent of the #56209 gate.
### Does this PR introduce _any_ user-facing change?
No. This is a codegen-only change; query results are unchanged.
### How was this patch tested?
Two new unit tests in `WholeStageCodegenSuite`:
- A **cheap non-leaf** common subexpression (`s.x > 5 AND s.x < 100` over
`struct<x:int>`, sharing
`GetStructField`): CSE-enabled generated code is identical to
CSE-disabled code -- i.e. it falls
back to the lazy, short-circuiting non-CSE path. This shape took the CSE
path before this change,
so the test genuinely pins the new behavior.
- A **non-cheap** common subexpression (`(a + b) > 0 AND (a + b) < 100`,
sharing `Add`): CSE-enabled
code differs from CSE-disabled code -- i.e. the gate still takes the CSE
path that computes the
shared result once.
The existing `FilterExec` CSE tests, which use genuine non-cheap common
subexpressions, still
exercise the CSE path and pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude (Claude Code)
Closes #56604 from cloud-fan/filterexec-cse-leaf-gate.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Uros Bojanic <[email protected]>
---
.../spark/sql/catalyst/optimizer/Optimizer.scala | 9 ++-
.../sql/execution/basicPhysicalOperators.scala | 17 ++++-
.../sql/execution/WholeStageCodegenSuite.scala | 72 ++++++++++++++++++++++
3 files changed, 96 insertions(+), 2 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index 87500f0ca514..077d6cbe8d47 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -1559,9 +1559,16 @@ object CollapseProject extends Rule[LogicalPlan] with
AliasHelper {
/**
* Check if the given expression is cheap that we can inline it.
+ *
+ * This is consumed both by logical-stage callers (which only ever see
`Attribute`) and by the
+ * `FilterExec` whole-stage-codegen CSE gate, which runs on predicates
already bound for codegen
+ * and so sees `BoundReference` instead. The `BoundReference` branch
therefore only fires on the
+ * codegen path -- logical plans never carry `BoundReference` -- and leaves
the logical callers
+ * unaffected.
*/
def isCheap(e: Expression): Boolean = e match {
- case _: Attribute | _: OuterReference => true
+ // `BoundReference` is the codegen-bound form of an `Attribute`; a slot
read, equally cheap.
+ case _: Attribute | _: OuterReference | _: BoundReference => true
case _ if e.foldable => true
// PythonUDF is handled by the rule ExtractPythonUDFs
case _: PythonUDF =>
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
index 88c74ab7adc4..8c96f1ff9579 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
@@ -31,6 +31,7 @@ import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.expressions.BindReferences.bindReferences
import org.apache.spark.sql.catalyst.expressions.codegen._
+import org.apache.spark.sql.catalyst.optimizer.CollapseProject
import org.apache.spark.sql.catalyst.plans.physical._
import org.apache.spark.sql.execution.joins.{ShuffledHashJoinExec,
SortMergeJoinExec}
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
@@ -316,12 +317,26 @@ case class FilterExec(condition: Expression, child:
SparkPlan)
// (e.g. decoding a decimal column for rows a cheaper earlier predicate
would reject), so we
// fall back to `generatePredicateCode`.
//
+ // A *cheap* common subexpression does not count either. Caching a cheap
load saves nothing:
+ // the non-CSE path already loads each column lazily into a variable on
demand, so taking the
+ // CSE path for it would only add the eager prologue that decodes every
referenced column up
+ // front. Note bare columns never reach this point:
`EquivalentExpressions` skips
+ // `LeafExpression`s (which includes `BoundReference`/`Attribute`), and
+ // `splitConjunctivePredicates` feeds each conjunct to a separate
`addExprTree` call, so a
+ // column repeated across conjuncts (e.g. the `c >= lo` / `c <= hi` that
`c BETWEEN lo AND hi`
+ // lowers to) is never recorded as a common subexpression. The
cheap-but-recorded case is a
+ // shared *non-leaf* such as a struct field access -- `s.x > 5 AND s.x <
100` shares
+ // `GetStructField(s, x)` -- which is just a slot read. Require a
non-cheap common
+ // subexpression (per `CollapseProject.isCheap`) so such filters keep the
lazy,
+ // short-circuiting path and only genuine repeated computation takes the
CSE path.
+ //
// `subexpressionElimination.filterExec.enabled` additionally gates this
path so it can be
// turned off independently of subexpression elimination elsewhere.
val (prologueCode, predicateCode) =
if (conf.subexpressionEliminationEnabled &&
conf.subexpressionEliminationFilterExecEnabled &&
otherPreds.nonEmpty &&
- otherPredsEquivalentExpressions.getCommonSubexpressions.nonEmpty) {
+ otherPredsEquivalentExpressions.getCommonSubexpressions
+ .exists(!CollapseProject.isCheap(_))) {
// Pre-evaluate input variables before CSE analysis: CSE clears
// ctx.currentVars[i].code as a side effect; without this
pre-evaluation, Janino
// fails when otherPreds reference the same input columns that CSE
already
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
index 886df9184aca..bcd2f5369932 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala
@@ -1225,6 +1225,78 @@ class WholeStageCodegenSuite extends SharedSparkSession
"CSE-disabled codegen (i.e. fall back to the lazy, short-circuiting
non-CSE path)")
}
+ test("SPARK-56032: FilterExec skips CSE codegen when the common
subexpression is cheap") {
+ // A column repeated across conjuncts never becomes a common subexpression
-- a bare column is a
+ // `LeafExpression`, which `EquivalentExpressions` skips, and
`splitConjunctivePredicates` feeds
+ // each conjunct to a separate `addExprTree` call. The realistic
cheap-but-recorded case is a
+ // shared *non-leaf* slot read such as a struct field access: `s.x > 5 AND
s.x < 100` shares
+ // `GetStructField(s, x)`. Caching that gains nothing over the non-CSE
path's lazy load, so the
+ // gate must fall back. (Pre-`isCheap`-gate this took the CSE path,
emitting the eager
+ // prologue.)
+ val schema = StructType(Seq(
+ StructField("s", StructType(Seq(StructField("x", IntegerType, nullable =
true))),
+ nullable = true)))
+ val data = spark.sparkContext.parallelize(Seq(
+ Row(Row(10)), Row(Row(3)), Row(Row(200)), Row(Row(50)), Row(Row(null)),
Row(null)))
+ val expected = Seq(Row(Row(10)), Row(Row(50)))
+
+ def filterCode(cseEnabled: Boolean): String = {
+ withSQLConf(
+ SQLConf.SUBEXPRESSION_ELIMINATION_ENABLED.key -> cseEnabled.toString,
+ SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ val df = spark.createDataFrame(data, schema)
+ // Both conjuncts share `GetStructField(s, x)`, a cheap non-leaf
common subexpression.
+ val filtered = df.where("s.x > 5 AND s.x < 100")
+ val plan = filtered.queryExecution.executedPlan
+ assert(plan.exists(_.isInstanceOf[WholeStageCodegenExec]),
+ "Filter should be in whole-stage codegen")
+ checkAnswer(filtered, expected)
+ codegenString(plan)
+ }
+ }
+
+ def normalize(code: String): String = code.replaceAll("#\\d+", "#")
+ assert(normalize(filterCode(cseEnabled = true)) ==
normalize(filterCode(cseEnabled = false)),
+ "With only a cheap common subexpression, CSE-enabled FilterExec codegen
should be " +
+ "identical to CSE-disabled codegen (i.e. fall back to the lazy,
short-circuiting " +
+ "non-CSE path)")
+ }
+
+ test("SPARK-56032: FilterExec takes CSE codegen when the common
subexpression is non-cheap") {
+ // The dual of the cheap-subexpression test: when `otherPreds` share a
genuinely non-cheap
+ // computation (`a + b`, whose `isCheap` is false), the gate must take the
CSE path so the
+ // shared result is computed once. Verify the CSE-enabled code differs
from CSE-disabled here,
+ // pinning down that the gate still fires for real repeated computation.
+ val schema = StructType(Seq(
+ StructField("a", IntegerType, nullable = true),
+ StructField("b", IntegerType, nullable = true)))
+ val data = spark.sparkContext.parallelize(Seq(
+ Row(1, 5), Row(60, 50), Row(10, 20), Row(0, 0), Row(null, 5)))
+ val expected = Seq(Row(1, 5), Row(10, 20))
+
+ def filterCode(cseEnabled: Boolean): String = {
+ withSQLConf(
+ SQLConf.SUBEXPRESSION_ELIMINATION_ENABLED.key -> cseEnabled.toString,
+ SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ val df = spark.createDataFrame(data, schema)
+ // Both conjuncts share `a + b`, a non-cheap common subexpression
worth eliminating.
+ val filtered = df.where("(a + b) > 0 AND (a + b) < 100")
+ val plan = filtered.queryExecution.executedPlan
+ assert(plan.exists(_.isInstanceOf[WholeStageCodegenExec]),
+ "Filter should be in whole-stage codegen")
+ checkAnswer(filtered, expected)
+ codegenString(plan)
+ }
+ }
+
+ def normalize(code: String): String = code.replaceAll("#\\d+", "#")
+ assert(normalize(filterCode(cseEnabled = true)) !=
normalize(filterCode(cseEnabled = false)),
+ "With a non-cheap common subexpression, CSE-enabled FilterExec codegen
should differ from " +
+ "CSE-disabled codegen (i.e. take the CSE path that computes the shared
result once)")
+ }
+
test("SPARK-56032: subexpressionElimination.filterExec.enabled gates
FilterExec CSE " +
"independently of subexpression elimination") {
// The conf disables CSE specifically for FilterExec while leaving
subexpression elimination
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]