peter-toth commented on code in PR #58635:
URL: https://github.com/apache/spark/pull/58635#discussion_r4495128548
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
}
+ currentApplicationSide
+ } else if (applicationHinted) {
Review Comment:
**Finding 2.** This is
[SPARK-58486](https://issues.apache.org/jira/browse/SPARK-58486). The Bloom
filter hashes a collated string by its bytes, while the join compares it under
the collation. So for a collated key that isn't a plain column, `'abc'` and
`'ABC'` match in the join but not in the filter. Without the hint, the ticket's
repro needs `applicationSideScanSizeThreshold` lowered. With the hint, it fails
at the default settings. With the ticket's tables, this returns 0 instead of
5,000:
```sql
SELECT /*+ RUNTIME_FILTER(d) */ count(*) FROM fact f JOIN dim d ON
substr(f.k, 1, 3) = d.key
WHERE d.cat = 'x'
```
Without the hint, the same query returns 5,000.
Until the ticket is fixed, the hinted Bloom path could refuse such a key, as
`allowMaterializedCache` already does for materialized caches:
```scala
} else if (applicationHinted &&
!(UnsafeRowUtils.isBinaryStable(applicationSideKey.dataType) &&
UnsafeRowUtils.isBinaryStable(creationSideKey.dataType))) {
hintBlocked("the join key has a collation the Bloom filter hash
does not follow")
currentApplicationSide
} else if (applicationHinted) {
```
With it, the query returns 5,000. `f.k = d.key` still gets its filter, and
`InjectRuntimeFilterSuite` passes.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/LogicalRelation.scala:
##########
@@ -53,6 +53,8 @@ case class LogicalRelation(
output = output.map(QueryPlan.normalizeExpressions(_, output)),
catalogTable = None)
+ override def isOutputRepeatable: Boolean = !isStreaming
Review Comment:
**Finding 4.** This trusts every non-streaming `BaseRelation`, but some of
them return different rows when read again:
- A V1 JDBC relation whose `dbtable` or `query` is itself a query, e.g. with
a `LIMIT`, `random()` or a sequence.
- A Kafka batch read whose `endingOffsets` is `latest`, which each scan
resolves again.
The filter subquery and the join each read the relation. I measured it with
an H2 sequence:
```scala
spark.read.jdbc(url, "(SELECT NEXT VALUE FOR s AS k) q", new Properties())
.createOrReplaceTempView("seq_side")
```
`SELECT /*+ RUNTIME_FILTER(t) */ count(*) FROM fact f JOIN seq_side t ON f.k
= t.k` returns 0. The same query without the hint returns 20,000. The filter is
built from one value of the sequence, and the join reads the next.
This is the V1 counterpart of the pushed DSv2 `LIMIT` from
[r4250017527](https://github.com/apache/spark/pull/58635#discussion_r4250017527),
which `repeatableScan` now rejects. The cache already has a check for the same
contract: `InMemoryRelation.hasRepeatableLogicalPlan` trusts only a
`HadoopFsRelation` with a known file format. A shape in line with it, which I
haven't run the suites with:
```scala
override def isOutputRepeatable: Boolean = !isStreaming &&
relation.isInstanceOf[HadoopFsRelation]
```
Other relations could then opt in, e.g. a JDBC relation over a plain table.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
}
+ currentApplicationSide
+ } else if (applicationHinted) {
+ // Unlike the heuristics below, a hinted side adds its own filter
even on a key that
+ // already has one.
+ runtimeFilterHintRejection(applicationSideKey, creationSide,
creationSideKey) match {
+ case Some(reason) =>
+ hintBlocked(reason)
+ currentApplicationSide
+ case None =>
+ injectFilter(applicationSideKey, currentApplicationSide,
FilterCreationSide(
Review Comment:
**Finding 1.** When the application side is a stream with a stateful
operator, `PushDownRuntimeFilters` moves this Bloom filter below that operator.
The filter is built again from the static side in every micro-batch. So rows it
drops in one batch never reach the operator's state, and a key the static side
gains later finds its earlier rows missing. The docs recommend this shape: "the
static side of a stream-static join can be hinted to prune the stream".
I ran this `StreamTest`, with `testcat` an `InMemoryTableCatalog` and
`autoBroadcastJoinThreshold = -1`:
```scala
sql("CREATE TABLE testcat.ns.dim (k INT, v STRING)")
sql("INSERT INTO testcat.ns.dim VALUES (1, 'a')")
val input = MemoryStream[Int]
val agg = input.toDF().groupBy($"value".as("k")).count()
val joined = agg.join(spark.table("testcat.ns.dim").hint("runtime_filter"),
"k")
testStream(joined, OutputMode.Update())(
AddData(input, 1, 2),
CheckNewAnswer((1, 1L, "a")),
Execute { _ => sql("INSERT INTO testcat.ns.dim VALUES (2, 'b')") },
AddData(input, 2),
CheckNewAnswer((2, 2L, "b")))
```
Results:
- With the hint, the head returns `[2,1,b]`. The base, which ignores the
hint, returns `[2,2,b]`.
- Without the hint, both return `[2,2,b]`.
- Without the hint, with `applicationSideScanSizeThreshold = 0` and a
selective static side (`where($"k" < 100)`), both return `[2,1,b]`. So the bug
exists already, and the hint makes it reachable with the default settings.
I tried skipping a streaming application side with a stateful operator at
the top of `tryInject`, for the hint and the heuristics alike:
```scala
if (applicationSide.isStreaming &&
applicationSide.exists(UnsupportedOperationChecker.isStatefulOperation)) {
if (applicationHinted) {
hintBlocked(s"the $sideName side is a stream with a stateful
operator")
}
currentApplicationSide
} else if (!canPrune) {
```
All three cases then return `[2,2,b]`. `InjectRuntimeFilterSuite` and
`streaming/available-now-runtime-filter-hint.sql` still pass. The guard covers
the heuristics too. If you'd rather keep this PR to the hint, and agree that
the unhinted path is a bug too, could you file a ticket for it? A stateful case
next to that golden file would pin the fix.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -578,3 +640,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:
+ * 1. 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;
+ * 2. 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,
+ * or an aggregate over an unstable value, causes and which makes every
aggregate over the
+ * rows unstable.
+ *
+ * The plan is rejected outright when its row set is unstable (a filter or
join condition consumes
+ * an unstable attribute, an inner generate uses an unstable generator, any
limit, offset or
+ * sample), when a leaf cannot promise the same rows again, and, each with its
own reason, when it
+ * calls a function the filter subquery cannot evaluate, 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 {
+
+ /**
+ * @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.
+ */
+ private case class Taint(unstable: AttributeSet, unstableMultiplicity:
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 if (containsExtractedFunction(plan) ||
+ // A DPP filter evaluates the key in its own subquery, which no UDF
extraction rewrites.
+ key.containsAnyPattern(PYTHON_UDF, EXTERNAL_UDF)) {
+ Some("the hinted side contains a Python UDF, Python UDTF or external
UDF, which a runtime " +
+ "filter cannot evaluate")
+ } 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)
+
+ def containsExtractedFunction(plan: LogicalPlan): Boolean = {
+ plan.containsAnyPattern(PYTHON_UDF, EXTERNAL_UDF) || plan.exists {
+ case g: Generate => g.generator.isInstanceOf[PythonUDTF]
+ case _ => false
+ }
+ }
+
+ /**
+ * 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. A scalar subquery fails on more than one row, so its
multiplicity counts too.
+ */
+ 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 _: ScalarSubquery =>
+ !t.unstableMultiplicity &&
s.plan.outputSet.intersect(t.unstable).isEmpty
+ 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 {
+ // Whether a materialized leaf returns the same rows is known only once it
is materialized.
+ case m: MaterializedLeafNode if m.materializedMetadata.isEmpty =>
+ Left(s"the hinted side reads ${m.nodeName}, which is not materialized
yet")
+
+ // The source may have absorbed an operator from the hinted side, so name
that rather than the
+ // scan the user did not write.
+ case s: DataSourceV2ScanRelation if !s.isOutputRepeatable =>
+ Left(s"the hinted side reads ${s.name} with an aggregate, limit, offset,
top-N or sample " +
+ "pushed into the source, which may return different rows when
evaluated again")
+
+ 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. With
stable grouping the
+ // groups occur once each, whatever the input multiplicity; with
unstable grouping, the
+ // number of groups that share the stable grouping values varies.
+ val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+ // A group of values that are equal but not identical, e.g. under a
case-insensitive
+ // collation, outputs whichever of them arrives first.
+ val representatives = AttributeSet(a.groupingExpressions
+ .filterNot(e =>
UnsafeRowUtils.isBinaryStable(e.dataType)).flatMap(_.references))
+ val unstable = a.aggregateExpressions.filter { e =>
+ !isStable(e, t.unstable) ||
+ referencesOutsideAggregates(e).intersect(representatives).nonEmpty ||
+ (e.exists(_.isInstanceOf[AggregateExpression]) &&
+ (!stableGroups || t.unstableMultiplicity || !isOrderIrrelevant(e)))
+ }
+ Taint(AttributeSet(unstable.map(_.toAttribute)), unstableMultiplicity =
!stableGroups)
+ }
+
+ 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)))
+ }
+
+ 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)
+ } else if (g.outer) {
+ Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput),
unstableMultiplicity = true))
Review Comment:
**Finding 6.** Accepting this is right for the key set. But `ColumnPruning`
can't drop an outer `Generate`, so the filter's copy of the side keeps the
non-deterministic generator. `SubqueryExpression.deterministic` includes the
plan, so the filter itself becomes non-deterministic, and pushdown won't move
it past some operators. I measured it with a hinted `t` = `SELECT k, v FROM dim
LATERAL VIEW OUTER explode(nd_array(k)) x AS v`:
- Against `(SELECT k, count(*) c FROM fact GROUP BY k) f`, the Bloom filter
stays above the `Aggregate`. With a stable `t`, it reaches the `fact` scan.
- Against a table partitioned by the join key, the hinted DPP filter isn't
pushed, so cleanup drops it, and a Bloom filter replaces it. With a stable `t`,
the DPP filter stays.
In both cases the hint counts as applied, and nothing is logged. Neither the
Bloom filter aggregate nor DPP's distinct-key aggregate depends on how many
times a row repeats. So the filter subquery and the DPP build plan could drop
an outer `Generate` whose output they don't use, which keeps them deterministic.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
}
+ currentApplicationSide
+ } else if (applicationHinted) {
+ // Unlike the heuristics below, a hinted side adds its own filter
even on a key that
+ // already has one.
+ runtimeFilterHintRejection(applicationSideKey, creationSide,
creationSideKey) match {
Review Comment:
**Finding 5.** This calls the shared check, which rejects a hinted side with
a Python UDF, a Python UDTF or an external UDF anywhere in it.
[r4250017548](https://github.com/apache/spark/pull/58635#discussion_r4250017548)
made it one check for both paths. DPP needs it, since its standalone subquery
runs the whole side. The Bloom filter subquery is column-pruned first, though,
and `injectBloomFilter` already checks the pruned subquery (line 122). So a
side that only carries a UDF result, in a column the filter doesn't read, is
rejected for nothing. PySpark code hits this easily: a dim with
`withColumn("x", py_udf(...))`, hinted and joined on a plain key.
I measured it with this query:
```sql
SELECT /*+ RUNTIME_FILTER(t) */ f.v, t.x
FROM fact f JOIN (SELECT k - 100 AS k, py_identity(k) AS x FROM dim WHERE
attr = 'x') t
ON f.k = t.k
```
- With the hint, it gets no Bloom filter and the "contains a Python UDF"
warning.
- Without the hint, the heuristics build one, so adding the hint removes a
filter.
- With `k + 1 AS x` instead of the UDF, the hinted query gets its filter.
Here I tried skipping the whole-side check and reporting the same reason
when `injectFilter` returns the side unchanged:
```scala
runtimeFilterHintRejection(applicationSideKey, creationSide,
creationSideKey,
checkFunctions = false) match {
case Some(reason) =>
hintBlocked(reason)
currentApplicationSide
case None =>
val injected = injectFilter(applicationSideKey,
currentApplicationSide,
FilterCreationSide(creationSideKey, creationSide,
useMaterializedThreshold = false, hinted = true))
if (injected eq currentApplicationSide) {
hintBlocked("the hinted side contains a Python UDF, Python
UDTF or external " +
"UDF, which a runtime filter cannot evaluate")
}
injected
}
```
`checkFunctions = false` skips only the `containsExtractedFunction(plan)`
clause in `RuntimeFilterSourceAnalysis.rejection`, and the key check stays.
With this change:
- The query gets its Bloom filter and returns the same 200,000 rows.
- `InjectRuntimeFilterSuite` passes, the Python UDTF test included.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -578,3 +640,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:
+ * 1. 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;
+ * 2. 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,
+ * or an aggregate over an unstable value, causes and which makes every
aggregate over the
+ * rows unstable.
+ *
+ * The plan is rejected outright when its row set is unstable (a filter or
join condition consumes
+ * an unstable attribute, an inner generate uses an unstable generator, any
limit, offset or
+ * sample), when a leaf cannot promise the same rows again, and, each with its
own reason, when it
+ * calls a function the filter subquery cannot evaluate, 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 {
+
+ /**
+ * @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.
+ */
+ private case class Taint(unstable: AttributeSet, unstableMultiplicity:
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 if (containsExtractedFunction(plan) ||
+ // A DPP filter evaluates the key in its own subquery, which no UDF
extraction rewrites.
+ key.containsAnyPattern(PYTHON_UDF, EXTERNAL_UDF)) {
+ Some("the hinted side contains a Python UDF, Python UDTF or external
UDF, which a runtime " +
+ "filter cannot evaluate")
+ } 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)
+
+ def containsExtractedFunction(plan: LogicalPlan): Boolean = {
+ plan.containsAnyPattern(PYTHON_UDF, EXTERNAL_UDF) || plan.exists {
+ case g: Generate => g.generator.isInstanceOf[PythonUDTF]
+ case _ => false
+ }
+ }
+
+ /**
+ * 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. A scalar subquery fails on more than one row, so its
multiplicity counts too.
+ */
+ 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 _: ScalarSubquery =>
+ !t.unstableMultiplicity &&
s.plan.outputSet.intersect(t.unstable).isEmpty
+ 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 {
+ // Whether a materialized leaf returns the same rows is known only once it
is materialized.
+ case m: MaterializedLeafNode if m.materializedMetadata.isEmpty =>
+ Left(s"the hinted side reads ${m.nodeName}, which is not materialized
yet")
+
+ // The source may have absorbed an operator from the hinted side, so name
that rather than the
+ // scan the user did not write.
+ case s: DataSourceV2ScanRelation if !s.isOutputRepeatable =>
+ Left(s"the hinted side reads ${s.name} with an aggregate, limit, offset,
top-N or sample " +
+ "pushed into the source, which may return different rows when
evaluated again")
+
+ 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 " +
Review Comment:
**Finding 7.** `InlineCTE` keeps a CTE when it is `MATERIALIZED` or not
deterministic. The kept CTE reaches this branch as a `CTERelationRef` leaf,
whose `isOutputRepeatable` is the default `false`. So this query gets no filter:
```sql
WITH d AS MATERIALIZED (SELECT k FROM dim WHERE attr = 'x')
SELECT /*+ RUNTIME_FILTER(d) */ f.v FROM fact f JOIN d ON f.k = d.k
```
It logs "the hinted side reads CTERelationRef, which may return different
rows when evaluated again". Without `MATERIALIZED`, the same query gets its
filter. Rejecting a non-deterministic CTE is right, but a `MATERIALIZED` one is
as repeatable as its definition. The analysis could follow the reference to the
definition in the enclosing `WithCTE`, though I haven't tried it. Otherwise
`CTERelationRef` could get its own reason, e.g. "the hinted side reads a CTE
that is not inlined", so the user can act on it.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
}
+ currentApplicationSide
+ } else if (applicationHinted) {
+ // Unlike the heuristics below, a hinted side adds its own filter
even on a key that
Review Comment:
**Finding 8.** This branch skips `hasBloomFilter`, so a hinted join builds
one filter per key pair. That includes two pairs with the same expression on
the application side:
```sql
SELECT /*+ RUNTIME_FILTER(d) */ f.v FROM fact f JOIN dim2 d ON f.k = d.k AND
f.k = d.k2
```
This stacks two `might_contain(..., xxhash64(k))` on `f.k`. Each one takes a
slot of `runtimeFilter.number.threshold` and a probe per row. Skipping a key
that got its filter earlier in this loop keeps what this comment is for, a
filter on a key that already has one from another join:
```scala
hasBloomFilter(currentApplicationSide, applicationSideKey) &&
!hasBloomFilter(applicationSide, applicationSideKey)
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -302,6 +350,11 @@ object InjectRuntimeFilter extends Rule[LogicalPlan] with
PredicateHelper with J
* - The filter creation side has a selective predicate, or its exact
materialized row count
* is smaller than the application side's distinct join-key count
* - The max filterApplicationSide scan size is greater than a configurable
threshold
+ *
+ * All three predict whether a filter pays off, so a [[RuntimeFilterHint]]
on the creation side
+ * (`hinted`) waives them: the creation side is used as it is, whatever its
shape, as long as it
+ * is a repeatable source, since the filter subquery re-executes it. Returns
the reason when no
+ * creation side is extracted; only a hinted one is reported.
Review Comment:
**Finding 9.** This paragraph describes a `hinted` parameter and a returned
reason. The method has neither. Hinted sides never reach it: they go through
`tryInject` and `runtimeFilterHintRejection`.
```suggestion
*
* A [[RuntimeFilterHint]] bypasses this method, which only estimates
whether a filter pays
* off: `tryInject` builds the hinted filter directly once
`runtimeFilterHintRejection` accepts
* the creation side.
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
Review Comment:
**Finding 10.** These two flags read inverted at the call sites. For the
left application side, the call passes `injectLeftHinted, injectRightHinted`.
So `creationHinted` is true exactly when the application side is the hinted
source and must not be filtered. And `applicationHinted` is true when the
creation side is hinted, which is the case the list above describes ("unless
the creation side is hinted"). A name such as `isHintedDirection` /
`isHintedSource` would make a swapped argument visible.
Separately, the `false` that `var appliedHint` starts with (line 506) is
overwritten at line 582 before anything reads it, so the variable can be
declared there.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
Review Comment:
**Finding 11.** `canPruneLeft` and `canPruneRight` (`joins.scala:467-475`)
don't list `Cross`, and `CROSS JOIN ... WHERE f.k = d.k` stays a `Cross` join
with an equi condition. So `SELECT /*+ RUNTIME_FILTER(d) */ f.v FROM fact f
CROSS JOIN dim2 d WHERE f.k = d.k` gets no filter and the warning "the left
side of a cross join cannot be pruned". The same query as an `INNER JOIN` gets
one. Matching `_: InnerLike` in both helpers would fix the hint and the
heuristics alike.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,56 +484,191 @@ 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 isRuntimeFilterHintDecided(join.hint) => join
+ // Only a hinted join has anything to do with Bloom filters disabled.
+ case join: Join if !bloomFilterEnabled &&
!hasRuntimeFilterHint(join.hint) => join
case join @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, _, _,
left, right, hint) =>
var newLeft = left
var newRight = right
- leftKeys.lazyZip(rightKeys).foreach((l, r) => {
- // Check if:
- // 1. There is already a DPP filter on the key
- // 2. The keys are simple cheap expressions
- if (filterCounter < numFilterThreshold &&
- !hasDynamicPruningSubquery(left, right, l, r) &&
- isSimpleExpression(l) && isSimpleExpression(r)) {
- val oldLeft = newLeft
- val oldRight = newRight
- // Check if:
- // 1. The current join type supports prune the left side with
runtime filter
- // 2. The current join is a shuffle join or a broadcast join that
- // has a shuffle below it
- // 3. There is no bloom filter on the left key yet
- val hasShuffle = isProbablyShuffleJoin(left, right, hint)
- if (canPruneLeft(joinType) && (hasShuffle ||
probablyHasShuffle(left)) &&
- !hasBloomFilter(newLeft, l)) {
- extractBeneficialFilterCreatePlan(left, right, l, r).foreach {
creationSide =>
- newLeft = injectFilter(l, newLeft, creationSide)
+ // A side hinted as the runtime filter source is the creation side, so
the filter is
+ // applied to the other side. An ambiguous hint is reported and
otherwise ignored, leaving
+ // the heuristics to decide.
+ val hintedSource = runtimeFilterSourceSide(hint)
+ val hinted = hintedSource.isDefined
+ val injectLeftHinted = hintedSource.contains(BuildRight)
+ val injectRightHinted = hintedSource.contains(BuildLeft)
+ if (isRuntimeFilterHintAmbiguous(hint)) {
+ reportHintNotApplied(join,
+ "the runtime filter source is ambiguous as both join sides are
hinted")
+ }
+ var appliedHint = false
+ // The first reason the hint could not be applied on a key. The hinted
side is the same
+ // for every key, so the first reason is as representative as any.
+ var notAppliedReason: Option[String] = None
+ def hintBlocked(reason: => String): Unit = {
+ if (notAppliedReason.isEmpty) notAppliedReason = Some(reason)
+ }
+ lazy val hasShuffle = isProbablyShuffleJoin(left, right, hint)
+ // Tries to filter `applicationSide` with a filter built from
`creationSide`. Returns the
+ // filtered side, or `currentApplicationSide` when no filter was
added, recording the
+ // reason when this direction is the hinted one. Requirements:
+ // 1. The join type supports pruning the application side
+ // 2. The application side is not the hinted source, which is never
itself filtered
+ // 3. The join is a shuffle join, or a broadcast join with a shuffle
below it -- an
+ // estimate of whether the filter pays off, so a hint waives it
+ // 4. There is no Bloom filter on the application side's key yet, and
the key is not fixed
+ // by a literal, unless the creation side is hinted
+ def tryInject(
+ applicationSide: LogicalPlan,
+ currentApplicationSide: LogicalPlan,
+ applicationSideKey: Expression,
+ creationSide: LogicalPlan,
+ creationSideKey: Expression,
+ canPrune: Boolean,
+ applicationHinted: Boolean,
+ creationHinted: Boolean,
+ sideName: String): LogicalPlan = {
+ if (!canPrune) {
+ if (applicationHinted) {
+ // `ExistenceJoin.sql` throws, as the type never appears in SQL.
+ val joinDescription = joinType match {
+ case _: ExistenceJoin => "an existence"
+ case _ => s"a ${joinType.sql.toLowerCase(Locale.ROOT)}"
}
+ hintBlocked(s"the $sideName side of $joinDescription join cannot
be pruned")
}
+ currentApplicationSide
+ } else if (applicationHinted) {
+ // Unlike the heuristics below, a hinted side adds its own filter
even on a key that
+ // already has one.
+ runtimeFilterHintRejection(applicationSideKey, creationSide,
creationSideKey) match {
+ case Some(reason) =>
+ hintBlocked(reason)
+ currentApplicationSide
+ case None =>
+ injectFilter(applicationSideKey, currentApplicationSide,
FilterCreationSide(
+ creationSideKey, creationSide, useMaterializedThreshold =
false, hinted = true))
+ }
+ } else if (creationHinted || !(hasShuffle ||
probablyHasShuffle(applicationSide)) ||
+ hasBloomFilter(currentApplicationSide, applicationSideKey)) {
+ currentApplicationSide
+ } else {
+ extractBeneficialFilterCreatePlan(
+ applicationSide, creationSide, applicationSideKey,
creationSideKey)
+ // Checked last, as computing the constraints may be costly.
+ .filterNot(_ => isFixedByLiteral(applicationSide,
applicationSideKey))
+ .map(injectFilter(applicationSideKey, currentApplicationSide, _))
+ .getOrElse(currentApplicationSide)
+ }
+ }
+ // A DPP filter prunes by whole partitions rather than by rows, so it
is preferred: a key
+ // it prunes gets no Bloom filter, while the other keys still may. The
hint is honored by
+ // a DPP filter that [[PartitionPruning]] marked as honoring it. A
hinted side that cannot
+ // honor the hint on a key gets DPP from its own estimates, as without
the hint, so such a
+ // DPP filter does not count as the hint applied.
+ val prunedKeys = leftKeys.lazyZip(rightKeys).map { (l, r) =>
Review Comment:
**Finding 12.** `runtimeFilterHintRejection` runs the full
`RuntimeFilterSourceAnalysis.analyze` walk of the hinted side, subquery plans
included, for every key pair:
- once in `PartitionPruning.honorsHint`;
- again here, in `prunedKeys` and in `tryInject`.
Only the final `isStable(key, ...)` depends on the key. Computing the side's
`Either[String, Taint]` once per join, then checking each key against it, would
do the same work in one pass.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -379,29 +432,49 @@ object InjectRuntimeFilter extends Rule[LogicalPlan] with
PredicateHelper with J
}
}
- // This checks if there is already a DPP filter, as this rule is called just
after DPP.
- @tailrec
- private def hasDynamicPruningSubquery(
- left: LogicalPlan,
- right: LogicalPlan,
- leftKey: Expression,
- rightKey: Expression): Boolean = {
- (left, right) match {
- case (Filter(DynamicPruningSubquery(pruningKey, _, _, _, _, _, _),
plan), _) =>
- pruningKey.fastEquals(leftKey) || hasDynamicPruningSubquery(plan,
right, leftKey, rightKey)
- case (_, Filter(DynamicPruningSubquery(pruningKey, _, _, _, _, _, _),
plan)) =>
- pruningKey.fastEquals(rightKey) ||
- hasDynamicPruningSubquery(left, plan, leftKey, rightKey)
+ /**
+ * The DPP filters on `applicationKey` built from `creationKey` that prune
`plan`. This rule runs
+ * after the DPP filters are pushed down and cleaned up, so only a surviving
one is found,
+ * wherever the pushdown left it; it is the one [[PartitionPruning]]
inserted for the same join
+ * key pair, whose pruning key it rewrote on the way down as the lineage
tracking does.
+ */
+ private def dynamicPruningFilters(
+ plan: LogicalPlan,
+ applicationKey: Expression,
+ creationKey: Expression): Seq[DynamicPruningSubquery] = {
+ if (!plan.containsPattern(DYNAMIC_PRUNING_SUBQUERY)) return Nil
+ findExpressionAndTrackLineageDown(applicationKey, plan).toSeq.flatMap {
case (trackedKey, _) =>
+ plan.flatMap {
+ case Filter(condition, _) => condition.collect {
+ case d: DynamicPruningSubquery if
d.pruningKey.semanticEquals(trackedKey) &&
+ d.broadcastKeyIndices.exists(i =>
d.buildKeys(i).semanticEquals(creationKey)) => d
+ }
+ case _ => Nil
+ }
+ }
+ }
+
+ /**
+ * Whether `plan` constrains `key` to a literal, e.g. a partition key fixed
by a static partition
+ * filter. Every row then has the same key, so a filter on it prunes next to
nothing.
+ */
+ private def isFixedByLiteral(plan: LogicalPlan, key: Expression): Boolean = {
Review Comment:
**Finding 3.** `isFixedByLiteral` came from the partition-key question in
the [decision
challenge](https://github.com/apache/spark/pull/58635#pullrequestreview-5611231620)
and
[r4346072936](https://github.com/apache/spark/pull/58635#discussion_r4346072936).
It reads all of the application side's constraints, though. So it also skips a
data column fixed by a literal, whether the literal is written or inferred
through the join. The base builds a Bloom filter there. The SPARK-58272 test
shows it: its `c2 = 8` had to become `c2 IN (8, 9)`.
Every application row has the same key, so the filter keeps all of them or
none:
- When the creation side has the literal, the filter keeps every row, and
skipping it saves its cost.
- When the creation side doesn't have it, the filter drops every row, and
the skip loses that.
I measured the second case with shuffle joins and
`applicationSideScanSizeThreshold = 0`:
- `fact(k = id % 10, v)`, 200k rows.
- `dim(k = id % 10 + 100, attr)`, 1k rows, with `attr = 'x'` when `id % 100
= 0`.
| fact rows shuffled | base, AQE off / on | head, AQE off / on |
|---|---|---|
| `fact f JOIN dim d ON f.k = d.k WHERE f.k = 5 AND d.attr = 'x'` | 0 / 0 |
20,000 / 0 |
| `dim d LEFT JOIN (SELECT * FROM fact WHERE k = 5) f ON d.k = f.k WHERE
d.attr = 'x'` | 0 / 0 | 20,000 / 20,000 |
The base has a Bloom filter in all four runs, and the head in none. Both
return the same rows (0 and 10).
- In the inner join, `d.k = 5` is inferred for `dim`, so `dim` is empty and
AQE drops the fact scan.
- In the left outer join nothing is inferred for `dim`, which keeps its 10
rows, so AQE can't help.
The filter's cost is bounded, since its creation side has to pass the size
threshold. What it saves can be the whole fact side's shuffle. So I'd drop the
skip, which gives a partition key fixed by a literal a Bloom filter too.
Without the skip, only `do not add bloom filter on a key fixed by a literal`
fails. If the partition-key plans should stay as they were, limit the skip to
the keys whose DPP filter `CleanupDynamicPruningFilters` drops for a literal,
e.g. by having that rule record them. Either way, the description and this doc
comment shouldn't say the filter prunes next to nothing.
--
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]