pan3793 commented on code in PR #58635:
URL: https://github.com/apache/spark/pull/58635#discussion_r4260743904
##########
sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala:
##########
@@ -1270,6 +1325,600 @@ class InjectRuntimeFilterSuite extends
SharedSparkSession
}
}
+ test("RUNTIME_FILTER hint waives the creation side's selective predicate
requirement") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ val query = "select * from bf1 join bf2 on bf1.c1 = bf2.c2"
+ // Without a selective predicate on either side there is no evidence a
filter pays off.
+ assertDidNotRewriteWithBloomFilter(query)
+ // The hint supplies that evidence, and picks which side to build from.
+ assertRewroteWithBloomFilter(
+ "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2")
+ assert(bloomFilterApplicationSideKeys(
+ "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2") == Set("c1"))
+ assert(bloomFilterApplicationSideKeys(
+ "select /*+ RUNTIME_FILTER(bf1) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2") == Set("c2"))
+ }
+ }
+
+ test("RUNTIME_FILTER hint waives the application side's scan size
threshold") {
+ // A threshold above the application side's scan size, so only the hint
can trigger a rewrite.
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "1GB",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ assertDidNotRewriteWithBloomFilter(
+ "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+ assertRewroteWithBloomFilter(
+ "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2")
+ }
+ }
+
+ test("RUNTIME_FILTER hint waives the creation side's size threshold") {
+ // A threshold below any creation side's size, so only the hint can
trigger a rewrite. The
+ // threshold estimates whether the filter is worth building; the filter's
own size is bounded
+ // by the max number of bits regardless.
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.RUNTIME_BLOOM_FILTER_CREATION_SIDE_THRESHOLD.key -> "1",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ assertDidNotRewriteWithBloomFilter(
+ "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+ assertRewroteWithBloomFilter(
+ "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2")
+ }
+ }
+
+ test("RUNTIME_FILTER hint waives the shuffle requirement") {
+ // `bf2` is below the broadcast threshold, so this is a broadcast join
with no shuffle below
+ // the application side, where a filter is not expected to pay off.
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000") {
+ assertDidNotRewriteWithBloomFilter(
+ "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+ assertRewroteWithBloomFilter(
+ "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 =
bf2.c2")
+ }
+ }
+
+ test("RUNTIME_FILTER hint waives the application side's single-leaf lineage
requirement") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ // The application side's key is an aggregate result, which cannot be
traced to a scan. The
+ // heuristics use that lineage to predict that the filter reaches a
scan; the filter above
+ // the aggregate is valid regardless, and prunes the aggregate's output
before the join.
+ val aggregatedApplicationSide =
+ """
+ |FROM (SELECT max(c1) AS c1 FROM bf1 GROUP BY b1) t
+ | JOIN bf2
+ | ON t.c1 = bf2.c2
+ """.stripMargin
+ assertDidNotRewriteWithBloomFilter(s"SELECT * $aggregatedApplicationSide
WHERE bf2.a2 = 62")
+ assertRewroteWithBloomFilter(
+ s"SELECT /*+ RUNTIME_FILTER(bf2) */ * $aggregatedApplicationSide")
+ }
+ }
+
+ test("RUNTIME_FILTER hint accepts a creation side the heuristics reject") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ // Left to itself the rule looks for a selective predicate over a scan,
a search an
+ // `Aggregate` stops, so this is not rewritten despite the selective
predicate below it. The
+ // hint designates the creation side instead of searching for one, so
the shape stops
+ // mattering.
+ val distinctSource =
+ """
+ |FROM bf1
+ | JOIN (SELECT DISTINCT c2 FROM bf2 WHERE a2 = 62) t
+ | ON bf1.c1 = t.c2
+ """.stripMargin
+ assertDidNotRewriteWithBloomFilter(s"SELECT * $distinctSource")
+ assertRewroteWithBloomFilter(s"SELECT /*+ RUNTIME_FILTER(t) */ *
$distinctSource")
+ // Likewise for a `Limit` the search does not descend through, here a
top-n of grouping
+ // keys, whose order is total because the keys are unique.
+ assertRewroteWithBloomFilter(
+ """
+ |SELECT /*+ RUNTIME_FILTER(t) */ *
+ |FROM bf1
+ | JOIN (SELECT c2 FROM bf2 WHERE a2 = 62 GROUP BY c2 ORDER BY
c2 LIMIT 5) t
+ | ON bf1.c1 = t.c2
+ """.stripMargin)
+ }
+ }
+
+ test("RUNTIME_FILTER hint builds a filter from a computed join key") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ // The filter is built from the hinted side's output, so a join key the
creation side computes
+ // rather than reads from a scan -- here an aggregate result -- works
like any other.
+ assertRewroteWithBloomFilter(
+ """
+ |SELECT /*+ RUNTIME_FILTER(t) */ *
+ |FROM bf1
+ | JOIN (SELECT max(c2) AS c2 FROM bf2 GROUP BY b2) t
+ | ON bf1.c1 = t.c2
+ """.stripMargin)
+ }
+ }
+
+ test("RUNTIME_FILTER hint takes effect through one mechanism") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "2000") {
+ // `bf5part` is partitioned by `f5`, and the selective `bf2.a2 = 62`
makes DPP prune it on
+ // that key. The hint is honored by that DPP filter, so no Bloom filter
is added: not on the
+ // key DPP already prunes, matching the unhinted behavior asserted
above, and not on the
+ // other join key either, where the heuristics would add one ("add bloom
filter if dpp
+ // filter exists on a different column" above).
+ val oneKey = "select /*+ RUNTIME_FILTER(bf2) */ * from bf5part join bf2
" +
+ "on bf5part.f5 = bf2.c2 where bf2.a2 = 62"
+ assertDidNotRewriteWithBloomFilter(oneKey)
+ assert(hasDynamicPruning(oneKey))
+ val twoKeys = "select /*+ RUNTIME_FILTER(bf2) */ * from bf5part join bf2
" +
+ "on bf5part.c5 = bf2.c2 and bf5part.f5 = bf2.f2 where bf2.a2 = 62"
+ assertDidNotRewriteWithBloomFilter(twoKeys)
+ assert(hasDynamicPruning(twoKeys))
+ }
+ }
+
+ test("RUNTIME_FILTER hint is honored by a DPP filter only when that filter
reaches the scan") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ spark.udf.register("bf_nondeterministic_true", udf((_: Int) =>
true).asNondeterministic())
+ // The hint gets a DPP filter on `bf5part`'s partition key, but a
barrier on the pruned side
+ // keeps it from being pushed to the scan, so it is dropped later: a
non-deterministic
+ // filter, or a window whose partitioning does not cover the key. The
Bloom filter needs no
+ // pushdown, so the hint is honored with one instead, and exactly one of
the two remains.
+ Seq(
+ ("SELECT f5 FROM bf5part WHERE bf_nondeterministic_true(f5)", 1),
+ ("SELECT f5 FROM (SELECT f5, row_number() OVER (PARTITION BY c5 ORDER
BY f5) rn " +
+ "FROM bf5part) WHERE rn = 1", 1),
+ // The DPP filter is pushed into both union branches, where the limit
blocks it and the
+ // `Range` cannot use it. The Bloom filter is pushed into both
branches too.
+ ("SELECT f5 FROM (SELECT f5 FROM bf5part ORDER BY f5 LIMIT 2) " +
+ "UNION ALL SELECT CAST(id AS INT) AS f5 FROM range(3)", 2)
+ ).foreach { case (prunedSide, numBloomFilters) =>
+ val query = "SELECT /*+ RUNTIME_FILTER(bf2) */ f.f5, bf2.c2 " +
+ s"FROM ($prunedSide) f JOIN bf2 ON f.f5 = bf2.c2"
+ assertRewroteWithBloomFilter(query, numBloomFilters)
+ assert(!hasDynamicPruning(query))
+ }
+ // A DPP filter that does reach the scan honors the hint on its own,
even when the pruned
+ // side is not deterministic elsewhere (here on the other side of a join
the DPP filter is
+ // pushed past), so no Bloom filter is added on top of it.
+ val reaching = "SELECT /*+ RUNTIME_FILTER(bf2) */ f.f5, f.nd, bf2.c2
FROM " +
+ "(SELECT p.f5, b.nd FROM bf5part p JOIN " +
+ "(SELECT c3, bf_nondeterministic_true(a3) AS nd FROM bf3) b ON p.c5 =
b.c3) f " +
+ "JOIN bf2 ON f.f5 = bf2.c2"
+ assertDidNotRewriteWithBloomFilter(reaching)
+ assert(hasDynamicPruning(reaching))
+ }
+ }
+
+ test("RUNTIME_FILTER hint is ignored for a creation side that is not
repeatable") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ // The filter subquery re-executes the creation side, so one that can
produce different rows
+ // or keys on re-evaluation could yield keys the join itself never sees
and prune rows that
+ // do match. Catalyst flags most of these as deterministic.
+ val reason = "the hinted side may produce different rows or join keys
when evaluated again"
+ spark.udf.register("bf_nondeterministic_array",
+ udf((i: Int) => Seq(i)).asNondeterministic())
+ def query(source: String): String =
+ s"SELECT /*+ RUNTIME_FILTER(t) */ * FROM bf1 JOIN ($source) t ON
bf1.c1 = t.c2"
+ Seq(
+ // A non-deterministic expression.
+ "SELECT c2 FROM bf2 WHERE rand() < 0.5",
+ // A limit keeps whichever rows arrive first: unordered, or ordered on
a key that is not
+ // unique, so that ties at the cutoff can be resolved differently.
+ "SELECT c2 FROM bf2 WHERE a2 = 62 LIMIT 5",
+ "SELECT c2 FROM bf2 WHERE a2 = 62 ORDER BY c2 LIMIT 5",
+ // A sample without a seed draws a different sample per evaluation.
+ "SELECT c2 FROM bf2 TABLESAMPLE (50 PERCENT)",
+ // A key computed by an order-dependent aggregate function.
+ "SELECT first(c2) AS c2 FROM bf2 GROUP BY b2",
+ "SELECT any_value(c2) AS c2 FROM bf2 GROUP BY b2",
+ // A key computed by a window function.
+ "SELECT row_number() OVER (ORDER BY c2) AS c2 FROM bf2",
+ // An inner generate drops the rows for which an unstable generator
yields nothing.
+ "SELECT c2 FROM bf2 LATERAL VIEW
explode(bf_nondeterministic_array(a2)) x AS v",
+ // A key computed by a subquery whose own plan is not repeatable.
+ "SELECT (SELECT max(c2) FROM bf2 TABLESAMPLE (50 PERCENT)) AS c2 FROM
bf2",
+ // An outer generate keeps every row but repeats it as many times as
the unstable
+ // generator yields values, so an aggregate over the rows is unstable.
+ "SELECT count(*) AS c2 FROM bf2 " +
+ "LATERAL VIEW OUTER explode(bf_nondeterministic_array(a2)) x AS v",
+ // An average of integers accumulates in floating point, which is not
associative.
+ "SELECT avg(c2) AS c2 FROM bf2 GROUP BY b2"
+ ).foreach { source =>
+ assertNoBloomFilters(query(source))
+ assertHintNotApplied(query(source), reason)
+ }
+ // A seeded sample over a scan is repeatable.
+ assertRewroteWithBloomFilter(
+ query("SELECT c2 FROM bf2 TABLESAMPLE (50 PERCENT) REPEATABLE (42)"))
+ }
+ }
+
+ test("RUNTIME_FILTER hint accepts a creation side whose unstable values do
not reach the key") {
+
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
-> "3000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ // Only the key values and the row set have to be repeatable. An
order-dependent value that
+ // is merely carried to the output, next to a stable key, does not
disqualify the side, and
+ // neither does a filter on a window aggregate whose frame is the whole
partition.
+ def query(source: String, key: String): String =
+ s"SELECT /*+ RUNTIME_FILTER(t) */ * FROM bf1 JOIN ($source) t ON
bf1.c1 = t.$key"
+ spark.udf.register("bf_nondeterministic_array",
+ udf((i: Int) => Seq(i)).asNondeterministic())
+ Seq(
+ ("SELECT b2, first(c2) AS c2 FROM bf2 GROUP BY b2", "b2"),
+ ("SELECT c2, v FROM bf2 LATERAL VIEW OUTER
explode(bf_nondeterministic_array(a2)) x AS v",
+ "c2"),
+ ("SELECT (SELECT max(c2) FROM bf2) AS c2, b2 FROM bf2", "c2"),
+ ("SELECT sum(c2) AS c2 FROM bf2 GROUP BY b2", "c2"),
+ ("SELECT c2, sum(a2) OVER (PARTITION BY b2 ORDER BY c2 " +
+ "ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS s FROM
bf2", "c2"),
+ ("SELECT c2 FROM (SELECT * FROM bf2) TABLESAMPLE (50 PERCENT)
REPEATABLE (42) x", "c2"),
+ ("SELECT c2, row_number() OVER (ORDER BY a2) AS rn FROM bf2", "c2"),
+ ("SELECT c2, sum(a2) OVER (PARTITION BY b2) AS s FROM bf2", "c2"),
+ ("SELECT c2 FROM (SELECT c2, sum(a2) OVER (PARTITION BY b2) AS s FROM
bf2) WHERE s > 0",
+ "c2")
+ ).foreach { case (source, key) =>
+ assertRewroteWithBloomFilter(query(source, key))
Review Comment:
Fixed in cbef594a838. Hinted tests join `bf4`, which shares `bf2`'s keys,
and each accepting test must return rows matching the unfiltered answer.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,55 +481,158 @@ object InjectRuntimeFilter extends Rule[LogicalPlan]
with PredicateHelper with J
private def tryInjectRuntimeFilter(plan: LogicalPlan): LogicalPlan = {
var filterCounter = 0
val numFilterThreshold =
conf.getConf(SQLConf.RUNTIME_FILTER_NUMBER_THRESHOLD)
+ val bloomFilterEnabled = conf.runtimeFilterBloomFilterEnabled
plan transformUp {
+ case join: Join if join.getTagValue(HINT_DECIDED).isDefined => join
Review Comment:
Fixed in cbef594a838. When the tag is lost, a Bloom filter already built
from the hinted side's key counts as the hint applied; tested with the
IN-subquery shape.
##########
docs/sql-ref-syntax-qry-select-hints.md:
##########
@@ -172,6 +172,40 @@ SELECT /*+ SHUFFLE_REPLICATE_NL(t1) */ * FROM t1 INNER
JOIN t2 ON t1.key = t2.ke
SELECT /*+ BROADCAST(t1), MERGE(t1, t2) */ * FROM t1 INNER JOIN t2 ON t1.key =
t2.key;
```
+### Runtime Filter Hints
+
+Runtime filter hints allow users to suggest which side of a join Spark should
build a runtime
+filter from, to prune the other side. Spark decides on its own whether a
runtime filter is worth
+building, based on statistics; the hint overrides that decision, but not the
requirements that make
Review Comment:
Fixed in cbef594a838. The docs state the key-set contract, that configured
limits may decline the hint, and that there is no opposite-direction fallback.
The `InjectRuntimeFilter` scaladoc now lists the simple-key requirement as
retained.
##########
docs/sql-ref-syntax-qry-select-hints.md:
##########
@@ -172,6 +172,55 @@ SELECT /*+ SHUFFLE_REPLICATE_NL(t1) */ * FROM t1 INNER
JOIN t2 ON t1.key = t2.ke
SELECT /*+ BROADCAST(t1), MERGE(t1, t2) */ * FROM t1 INNER JOIN t2 ON t1.key =
t2.key;
```
+### Runtime Filter Hints
+
+A runtime filter prunes one side of a join using the join key values found on
the other side, so
+rows that cannot match are discarded early. Spark decides on its own whether
such a filter is worth
+building, based on estimates of how much data it would save. Runtime filter
hints let users make
+that decision instead, for the cases where the estimates are unavailable or
wrong.
+
+#### Runtime Filter Hints Types
+
+* **RUNTIME_FILTER**
+
+ Suggests that Spark build a runtime filter from the hinted relation and
use it to prune the
+ other side of the join. Use it when the hinted side is known to match only
a small fraction
+ of the other side, but Spark does not choose a runtime filter on its own,
typically because
+ table statistics are missing or misleading. The hinted side may be any
relation or subquery,
+ and is never itself pruned. The hint does not choose how the pruning is
done; Spark picks the
+ mechanism. `RUNTIME_FILTER` can be combined with a join strategy hint.
+
+The hint overrides Spark's cost estimates, but not the requirements that make
a runtime filter
+correct, so Spark is not guaranteed to follow it. A side that join semantics
forbid pruning is
+never pruned, e.g. the left side of a `LEFT OUTER` join, whose rows must all
appear in the output.
+The hinted side must produce the same rows each time it is evaluated, since
building the filter
Review Comment:
Fixed in cbef594a838.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -578,3 +623,286 @@ trait JoinSelectionHelper extends Logging {
conf.getConfString("spark.sql.join.forceApplyShuffledHashJoin", "false")
== "true"
}
}
+
+/**
+ * Decides whether a plan can serve as the source of a runtime filter on a
join key. A runtime
+ * filter evaluates its source separately from the join, so the rows the
source produces and the
+ * key values they carry must be the same in both evaluations, or the filter
could prune rows the
+ * join itself matches. `deterministic` is not enough for that: Spark flags
order-dependent
+ * computations such as first, last, row_number or an unordered LIMIT as
deterministic, and a leaf
+ * such as an RDD that is not checkpointed may return different rows on each
computation.
+ *
+ * The plan is walked bottom-up, tracking:
+ * - output attributes whose values are unstable: they come from a
non-deterministic expression, an
+ * order-dependent aggregate or window function, a subquery whose plan fails
this same analysis,
+ * or an expression over such an attribute;
+ * - whether the row multiplicity is unstable, i.e. every row is still present
but may be repeated
+ * a different number of times, which an outer generate with an unstable
generator causes and
+ * which makes every aggregate over the rows unstable.
+ * The plan is rejected outright when its row set is unstable (a filter, join
condition or
+ * grouping consumes an unstable attribute, an inner generate uses an unstable
generator, a sample
+ * is unseeded or over anything but a repeatable leaf, a limit is over
anything but a total order),
+ * when a leaf cannot promise the same rows again, and, each with its own
reason, when an operator
+ * would duplicate an observation or is not analyzed. The source qualifies
when the key references
+ * no unstable attribute. Values that are unstable but only carried to the
output (a `first(name)`
+ * next to a `GROUP BY id`, a row number next to the key) do not disqualify it.
+ */
+private[optimizer] object RuntimeFilterSourceAnalysis extends AliasHelper {
+
+ /**
+ * @param unstable output attributes whose values depend on evaluation order
or on chance.
+ * @param unstableMultiplicity whether rows may be repeated a different
number of times per
+ * evaluation, while each row stays present.
+ * @param totallyOrdered whether the rows are in a total order on stable
keys, so that a limit
+ * over them keeps the same rows every time.
+ */
+ private case class Taint(
+ unstable: AttributeSet,
+ unstableMultiplicity: Boolean = false,
+ totallyOrdered: Boolean = false)
+
+ private val NotRepeatable =
+ "the hinted side may produce different rows or join keys when evaluated
again"
+
+ /** Why `plan` is not a repeatable source of `key`, or None when it is. */
+ def rejection(plan: LogicalPlan, key: Expression): Option[String] = {
+ if (plan.isStreaming) {
+ Some("the hinted side is a stream")
+ } else {
+ analyze(plan) match {
+ case Left(reason) => Some(reason)
+ case Right(t) if !isStable(key, t.unstable) => Some(NotRepeatable)
+ case _ => None
+ }
+ }
+ }
+
+ def isRepeatableExpression(e: Expression): Boolean = isStable(e,
AttributeSet.empty)
+
+ /**
+ * Whether `e` yields the same value on every evaluation. A subquery counts
as deterministic
+ * when its plan is, which is the very check this analysis replaces, so its
plan is analyzed
+ * too.
+ */
+ private def isStable(e: Expression, unstable: AttributeSet): Boolean = {
+ e.deterministic && e.references.intersect(unstable).isEmpty && !e.exists {
+ case s: SubqueryExpression => !isRepeatableSubquery(s)
+ case _ => false
+ }
+ }
+
+ /**
+ * A subquery is repeatable when its row set is, and so is whatever of its
output the subquery
+ * exposes: the build keys of a DPP filter, nothing but existence for
EXISTS, the output columns
+ * otherwise.
+ */
+ private def isRepeatableSubquery(s: SubqueryExpression): Boolean = {
+ !s.plan.isStreaming && analyze(s.plan).exists { t =>
+ s match {
+ case d: DynamicPruningSubquery =>
+ d.broadcastKeyIndices.forall(i => isStable(d.buildKeys(i),
t.unstable))
+ case _: Exists => true
+ case _ => s.plan.outputSet.intersect(t.unstable).isEmpty
+ }
+ }
+ }
+
+ /** Returns the taint of `plan`'s output, or the reason its row set is not
repeatable. */
+ private def analyze(plan: LogicalPlan): Either[String, Taint] = plan match {
+ case l: LeafNode =>
+ if (l.isOutputRepeatable) {
+ Right(Taint(AttributeSet.empty))
+ } else {
+ Left(s"the hinted side reads ${l.nodeName}, which may return different
rows when " +
+ "evaluated again")
+ }
+
+ case p: Project => analyze(p.child).map { t =>
+ t.copy(unstable =
+ AttributeSet(p.projectList.filterNot(isStable(_,
t.unstable)).map(_.toAttribute)))
+ }
+
+ case f: Filter => analyze(f.child).flatMap { t =>
+ if (isStable(f.condition, t.unstable)) Right(t) else Left(NotRepeatable)
+ }
+
+ case j: Join => analyze(j.left).flatMap { l =>
+ analyze(j.right).flatMap { r =>
+ val unstable = l.unstable ++ r.unstable
+ if (j.condition.forall(isStable(_, unstable))) {
+ Right(Taint(unstable, l.unstableMultiplicity ||
r.unstableMultiplicity))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+ }
+
+ case a: Aggregate => analyze(a.child).map { t =>
+ // Grouping on an unstable value changes which rows form a group, and
unstable multiplicity
+ // changes how many rows each group has, so every aggregate result then
depends on them; a
+ // grouping expression's own value is as stable as its input. The groups
themselves occur
+ // once each, whatever the input multiplicity.
+ val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+ val unstable = a.aggregateExpressions.filter { e =>
+ !isStable(e, t.unstable) ||
+ (e.exists(_.isInstanceOf[AggregateExpression]) &&
+ (!stableGroups || t.unstableMultiplicity || !isOrderIrrelevant(e)))
+ }
+ Taint(AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case w: Window => analyze(w.child).map { t =>
+ val stablePartitions = w.partitionSpec.forall(isStable(_, t.unstable))
+ val unstable = w.windowExpressions.filter { e =>
+ !stablePartitions || t.unstableMultiplicity || !isStable(e,
t.unstable) ||
+ !isOrderIrrelevantWindow(e)
+ }
+ t.copy(unstable = t.unstable ++
AttributeSet(unstable.map(_.toAttribute)),
+ totallyOrdered = false)
+ }
+
+ case u: Union =>
+ val taints = u.children.map(analyze)
+ taints.collectFirst { case Left(reason) => Left(reason) }.getOrElse {
+ val unstable = u.output.zipWithIndex.collect {
+ case (attr, i) if u.children.zip(taints).exists {
+ case (child, Right(taint)) =>
taint.unstable.contains(child.output(i))
+ case _ => false
+ } => attr
+ }
+ Right(Taint(AttributeSet(unstable),
+ taints.exists(_.exists(_.unstableMultiplicity))))
+ }
+
+ // An inner generate drops the rows for which the generator yields
nothing, so an unstable
+ // generator changes the row set; an outer generate keeps every row but
repeats it as many
+ // times as the generator yields values, so an unstable one changes the
multiplicity.
+ case g: Generate => analyze(g.child).flatMap { t =>
+ if (isStable(g.generator, t.unstable)) {
+ Right(t.copy(totallyOrdered = false))
+ } else if (g.outer) {
+ Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput),
unstableMultiplicity = true))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+
+ case e: Expand => analyze(e.child).map { t =>
+ val unstable = e.output.zipWithIndex.collect {
+ case (attr, i) if e.projections.exists(p => !isStable(p(i),
t.unstable)) => attr
+ }
+ Taint(AttributeSet(unstable), t.unstableMultiplicity)
+ }
+
+ case s: Sort => analyze(s.child).map { t =>
+ val stableOrder = s.order.forall(o => isStable(o.child, t.unstable))
+ t.copy(totallyOrdered = s.global && stableOrder &&
!t.unstableMultiplicity &&
+ sortedOnUniqueKey(s.child, s.order.map(_.child)))
+ }
+
+ // A limit keeps whichever rows arrive first unless the order is total.
+ case l @ (_: GlobalLimit | _: LocalLimit | _: Offset | _: Tail) =>
+ analyze(l.children.head).flatMap { t =>
+ if (t.totallyOrdered) Right(t) else Left(NotRepeatable)
+ }
+
+ // A sample draws a fresh seed per evaluation unless one is given, and
depends on the input
+ // row order and partitioning even then: only a seeded sample over a
repeatable leaf, through
+ // projections and filters that keep both, is repeatable. A predicate with
a subquery is not
+ // such a filter, as its plan may differ between the two evaluations.
+ case s: Sample =>
+ @tailrec
+ def isOrderPreservingScan(p: LogicalPlan): Boolean = p match {
+ case Project(projectList, child) if
projectList.forall(_.deterministic) =>
+ isOrderPreservingScan(child)
+ case Filter(condition, child)
+ if condition.deterministic &&
!SubqueryExpression.hasSubquery(condition) =>
+ isOrderPreservingScan(child)
+ case l: LeafNode => l.isOutputRepeatable
+ case _ => false
+ }
+ if (s.seed.isDefined && isOrderPreservingScan(s.child)) {
+ analyze(s.child)
+ } else {
+ Left(NotRepeatable)
+ }
+
+ // Each distinct row occurs once, whatever the input multiplicity.
+ case d: Distinct => analyze(d.child).map(t => Taint(t.unstable))
+
+ // The rows and their values are unchanged; a shuffle loses the order.
+ case _: SubqueryAlias | _: Repartition | _: RepartitionByExpression | _:
RebalancePartitions =>
+ analyze(plan.children.head).map(t => t.copy(totallyOrdered = false))
+
+ // The filter would evaluate the observation a second time under the same
name.
+ case _: CollectMetrics =>
+ Left("the hinted side contains CollectMetrics, whose observation the
runtime filter " +
+ "would evaluate twice")
+
+ // Anything else, e.g. a typed operator or a script transformation, is not
analyzed.
+ case _ =>
+ Left(s"the hinted side contains ${plan.nodeName}, which cannot be
checked for repeatability")
+ }
+
+ /**
+ * Whether an expression's value is independent of the order its input rows
arrive in. Only
+ * aggregate functions whose result is exact whatever the accumulation order
qualify: floating
+ * point sums are not associative, so a sum over a floating point input, an
average with a
+ * floating point accumulator (every non-decimal, non-interval input) and
the central moments
+ * are order-dependent. A Bloom filter aggregate, which
[[InjectRuntimeFilter]] injects for an
+ * inner join, merges commutatively.
+ */
+ private def isOrderIrrelevant(e: Expression): Boolean = e match {
+ case _: AttributeReference => true
+ case ae: AggregateExpression =>
isOrderIrrelevantFunction(ae.aggregateFunction)
+ case _: UserDefinedExpression => false
+ case _ => e.children.forall(isOrderIrrelevant)
+ }
+
+ private def isOrderIrrelevantFunction(f: AggregateFunction): Boolean = f
match {
+ case _: Min | _: Max | _: Count | _: BitAggregate | _:
BloomFilterAggregate => true
+ case s: Sum => !isFloatingPoint(s.child.dataType)
+ case a: Average => !isFloatingPoint(a.sumDataType)
+ case _ => false
+ }
+
+ private def isFloatingPoint(dataType: DataType): Boolean = dataType match {
+ case FloatType | DoubleType => true
+ case _ => false
+ }
+
+ /**
+ * A window function's value depends on the row order within its frame,
unless the frame is
+ * the whole partition and the function is order-irrelevant.
+ */
+ private def isOrderIrrelevantWindow(e: NamedExpression): Boolean = e match {
+ case Alias(WindowExpression(_: AggregateExpression, spec), _) =>
+ val wholePartition = spec.frameSpecification match {
+ case SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing)
=> true
+ case UnspecifiedFrame => spec.orderSpec.isEmpty
+ case _ => false
+ }
+ wholePartition && isOrderIrrelevant(e)
+ case _ => false
+ }
+
+ /**
+ * Whether `sortKeys` cover a key of `plan` that is proven unique, so that
sorting on them is a
+ * total order. The only uniqueness Catalyst can establish is an aggregate's
grouping keys.
Review Comment:
Fixed in cbef594a838. The helper is replaced by `LogicalPlan.distinctKeys`.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveHints.scala:
##########
@@ -77,22 +78,27 @@ object ResolveHints {
* is not aliased differently), subquery, or common table expression that
match the specified
* name.
*
+ * [[RuntimeFilterHint]] takes the same per-relation form, e.g.
"RUNTIME_FILTER(a)", and is
+ * resolved here too, onto the same [[HintInfo]] as a join strategy hint on
that relation, so
+ * the two can accompany each other.
+ *
* The hint resolution works by recursively traversing down the query plan
to find a relation or
* subquery that matches one of the specified relation aliases. The
traversal does not go past
* beyond any view reference, with clause or subquery alias.
*
* This rule must happen before common table expressions.
*/
- object ResolveJoinStrategyHints extends Rule[LogicalPlan] {
+ object ResolveJoinHints extends Rule[LogicalPlan] {
Review Comment:
Fixed in cbef594a838.
--
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]