-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-58757][SQL] Allow CollapseWindow to merge windows with an empty order spec #57986
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1761,9 +1761,39 @@ object CollapseWindow extends Rule[LogicalPlan] { | |
| s1.zip(s2).forall(e => e._1.semanticEquals(e._2)) | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if the given window expression can still be evaluated correctly when the rows | ||
| * of the partition are reordered, so that it can be merged into another window with a different | ||
| * (non-empty) order spec. | ||
| * | ||
| * The frame determines whether reordering is safe. When the frame is the whole partition | ||
| * (`UNBOUNDED PRECEDING` to `UNBOUNDED FOLLOWING`), it always covers all the rows of the | ||
| * partition regardless of the ordering, so reordering changes only the order in which the rows | ||
| * are seen, never which rows are in the frame. Since the order spec of the window is empty, | ||
| * the query does not fix the row order, so evaluating its expressions under any ordering | ||
| * yields a valid result, even though the value may differ for order-dependent expressions | ||
| * such as `first`, `collect_list`, or floating-point `sum`/`avg`. On the other hand, a bounded | ||
| * frame (e.g. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`) is order-sensitive: which | ||
| * rows are in the frame depends on the ordering, so even `count` or `sum` would change value, | ||
| * and such a window must not be merged. | ||
| */ | ||
| private def canEvaluateUnderAnyOrder(windowExpression: NamedExpression): Boolean = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 5. (Alternative design, not a defect -- take it or leave it.) The safety argument is much easier to make one layer down. So a physical rule beside it could merge two adjacent
Counter-argument, and it is a real one: far more work than this two-line change -- |
||
| windowExpression match { | ||
| case Alias(WindowExpression(_, WindowSpecDefinition(_, _, | ||
| SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing))), _) => true | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 7. The frame type is left free here, so |
||
| case _ => false | ||
| } | ||
|
|
||
| private def windowsCompatible(w1: Window, w2: Window): Boolean = { | ||
| specCompatible(w1.partitionSpec, w2.partitionSpec) && | ||
| specCompatible(w1.orderSpec, w2.orderSpec) && | ||
| // The order specs can differ when one of them is empty, as long as the window expressions | ||
| // of the window with the empty order spec are safe to evaluate under any row order. In that | ||
| // case, they can be evaluated under the non-empty order spec of the other window. | ||
| (specCompatible(w1.orderSpec, w2.orderSpec) || | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 1. // InferWindowGroupLimit.scala:78
case Alias(WindowExpression(windowFunction, WindowSpecDefinition(_, _,
SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))), _)
if !windowFunction.isInstanceOf[SizeBasedWindowFunction] => true
The shape is reachable. Trading a per-partition early stop for one saved The cheap fix is to drop the child-empty branch and keep only the parent-empty one: (specCompatible(w1.orderSpec, w2.orderSpec) ||
(w1.orderSpec.isEmpty && w2.orderSpec.nonEmpty &&
w1.windowExpressions.forall(canEvaluateUnderAnyOrder))) &&That direction cannot lose a |
||
| (w1.orderSpec.isEmpty && w2.orderSpec.nonEmpty && | ||
| w1.windowExpressions.forall(canEvaluateUnderAnyOrder)) || | ||
| (w2.orderSpec.isEmpty && w1.orderSpec.nonEmpty && | ||
| w2.windowExpressions.forall(canEvaluateUnderAnyOrder))) && | ||
| w1.references.intersect(w2.windowOutputSet).isEmpty && | ||
| w1.windowExpressions.nonEmpty && w2.windowExpressions.nonEmpty && | ||
| // This assumes Window contains the same type of window expressions. This is ensured | ||
|
|
@@ -1776,13 +1806,19 @@ object CollapseWindow extends Rule[LogicalPlan] { | |
| _.containsPattern(WINDOW), ruleId) { | ||
| case w1 @ Window(we1, _, _, w2 @ Window(we2, _, _, grandChild, _), _) | ||
| if windowsCompatible(w1, w2) => | ||
| w1.copy(windowExpressions = we2 ++ we1, child = grandChild) | ||
| w1.copy( | ||
| orderSpec = if (w1.orderSpec.nonEmpty) w1.orderSpec else w2.orderSpec, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 4. After this the operator's // EliminateWindowPartitions.scala:36 -- rewrites the expression's spec, not just the operator's
val newWsd = wsd.copy(partitionSpec = ps.filter(!_.foldable))I traced the readers and nothing breaks today: only |
||
| windowExpressions = we2 ++ we1, | ||
| child = grandChild) | ||
|
|
||
| case w1 @ Window(we1, _, _, Project(pl, w2 @ Window(we2, _, _, grandChild, _)), _) | ||
| if windowsCompatible(w1, w2) && w1.references.subsetOf(grandChild.outputSet) => | ||
| Project( | ||
| pl ++ w1.windowOutputSet, | ||
| w1.copy(windowExpressions = we2 ++ we1, child = grandChild)) | ||
| w1.copy( | ||
| orderSpec = if (w1.orderSpec.nonEmpty) w1.orderSpec else w2.orderSpec, | ||
| windowExpressions = we2 ++ we1, | ||
| child = grandChild)) | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,11 @@ package org.apache.spark.sql.catalyst.optimizer | |
|
|
||
| import org.apache.spark.sql.catalyst.dsl.expressions._ | ||
| import org.apache.spark.sql.catalyst.dsl.plans._ | ||
| import org.apache.spark.sql.catalyst.expressions.{ | ||
| CurrentRow, RowFrame, RowNumber, SpecifiedWindowFrame, | ||
| UnboundedFollowing, UnboundedPreceding} | ||
| import org.apache.spark.sql.catalyst.expressions.aggregate.{ | ||
| AggregateExpression, Complete, Count, First, Sum} | ||
| import org.apache.spark.sql.catalyst.plans.PlanTest | ||
| import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan} | ||
| import org.apache.spark.sql.catalyst.rules.RuleExecutor | ||
|
|
@@ -168,4 +173,164 @@ class CollapseWindowSuite extends PlanTest { | |
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("collapse windows when one has an empty order spec " + | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 2. All six new tests run
val batches = Batch("...", FixedPoint(10),
CollapseWindow, CollapseProject, RemoveNoopOperators, PushDownPredicates,
InferWindowGroupLimit) :: Nildriven by
|
||
| "(row_number + count over the whole partition)") { | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val cnt = windowExpr( | ||
| AggregateExpression(Count(c), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("cnt") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
| .window(Seq(cnt), partitionSpec1, Nil) | ||
|
|
||
| val analyzed = query.analyze | ||
| val optimized = Optimize.execute(analyzed) | ||
| assert(analyzed.output === optimized.output) | ||
|
|
||
| val correctAnswer = testRelation | ||
| .window(Seq(rk, cnt), partitionSpec1, orderSpec1) | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("collapse windows when the empty-order window has multiple window expressions") { | ||
| // Every window expression of the empty-order window must be order-insensitive for the merge. | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val cnt = windowExpr( | ||
| AggregateExpression(Count(c), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("cnt") | ||
| val sm = windowExpr( | ||
| AggregateExpression(Sum(b), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("sm") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
| .window(Seq(cnt, sm), partitionSpec1, Nil) | ||
|
|
||
| val analyzed = query.analyze | ||
| val optimized = Optimize.execute(analyzed) | ||
| assert(analyzed.output === optimized.output) | ||
|
|
||
| val correctAnswer = testRelation | ||
| .window(Seq(rk, cnt, sm), partitionSpec1, orderSpec1) | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("collapse windows when the empty-order window has first() over the whole partition") { | ||
| // `first` is non-deterministic when the order is not determined by the query, so evaluating it | ||
| // under the other window's order spec yields a valid result. | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val fr = windowExpr( | ||
| First(a, ignoreNulls = true).toAggregateExpression(), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("fr") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
| .window(Seq(fr), partitionSpec1, Nil) | ||
|
|
||
| val analyzed = query.analyze | ||
| val optimized = Optimize.execute(analyzed) | ||
| assert(analyzed.output === optimized.output) | ||
|
|
||
| val correctAnswer = testRelation | ||
| .window(Seq(rk, fr), partitionSpec1, orderSpec1) | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("don't collapse windows when the empty-order window has a bounded frame") { | ||
| // The frame `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` is order-sensitive: which rows | ||
| // fall in the frame depends on the ordering, so the window cannot be evaluated under the other | ||
| // window's order spec. | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val cnt = windowExpr( | ||
| AggregateExpression(Count(c), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("cnt") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
| .window(Seq(cnt), partitionSpec1, Nil) | ||
|
|
||
| val optimized = Optimize.execute(query.analyze) | ||
| val correctAnswer = query.analyze | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("collapse windows when the empty-order window is the inner window") { | ||
| // The empty-order window can also be the child of the ordered window. In that case its | ||
| // expressions are evaluated under the ordered window's order spec, which is valid because all | ||
| // of them are order-insensitive. | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val cnt = windowExpr( | ||
| AggregateExpression(Count(c), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("cnt") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(cnt), partitionSpec1, Nil) | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
|
|
||
| val analyzed = query.analyze | ||
| val optimized = Optimize.execute(analyzed) | ||
| assert(analyzed.output === optimized.output) | ||
|
|
||
| val correctAnswer = testRelation | ||
| .window(Seq(cnt, rk), partitionSpec1, orderSpec1) | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
|
|
||
| test("collapse windows with a Project between them when one has an empty order spec") { | ||
| // The same merge applies when a Project sits between the two windows and only passes through | ||
| // columns that are available below the inner window (SPARK-34565 shape). | ||
| val rk = windowExpr( | ||
| RowNumber(), | ||
| windowSpec(partitionSpec1, orderSpec1, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, CurrentRow))).as("rk") | ||
| val cnt = windowExpr( | ||
| AggregateExpression(Count(c), Complete, isDistinct = false, None), | ||
| windowSpec(partitionSpec1, Nil, | ||
| SpecifiedWindowFrame(RowFrame, UnboundedPreceding, UnboundedFollowing))).as("cnt") | ||
|
|
||
| val query = testRelation | ||
| .window(Seq(cnt), partitionSpec1, Nil) | ||
| .select($"a", $"b", $"c", $"cnt") | ||
| .window(Seq(rk), partitionSpec1, orderSpec1) | ||
| .select($"a", $"b", $"c", $"cnt", $"rk") | ||
|
|
||
| val analyzed = query.analyze | ||
| val optimized = Optimize.execute(analyzed) | ||
| assert(analyzed.output === optimized.output) | ||
|
|
||
| val correctAnswer = testRelation | ||
| .window(Seq(cnt, rk), partitionSpec1, orderSpec1) | ||
| .select(a, b, c, $"cnt", $"rk") | ||
| .analyze | ||
|
|
||
| comparePlans(optimized, correctAnswer) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Finding 6. The object scaladoc just above (
Optimizer.scala:1753-1757) still states the old precondition:Worth extending with the empty-order case -- that is the doc a reader hits first.