Skip to content

Commit a557928

Browse files
committed
[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback (incl. SPARK-54336)
Rewrites of bloom-filter expressions to their Velox variants are split by where the expressions become visible in the optimizer pipeline: - User-facing might_contain(<scalar subquery>, <non-literal>) pairs are rewritten by BloomFilterMightContainJointRewriteRule, now a logical rule registered via injectOptimizerRule (Operator Optimization batch). This bakes the substitution into the originalPlan snapshot ExpandFallbackPolicy uses when promoting a stage fallback to a whole-stage AQE fallback, so both sides stay on the same serialized byte format even if stages revert to JVM execution. This fixes the GLUTEN-12013 crash (java.io.IOException: Unexpected Bloom filter version number). - Literal-valued pairs (SPARK-54336) are left fully vanilla on both sides, preserving vanilla NULL-on-empty-input semantics and a consistent version=0 byte format. - Runtime-filter pairs are injected by Spark's InjectRuntimeFilter, which runs in a later SparkOptimizer batch than the Operator Optimization batch hosting the logical rule, so the logical rule never sees them. They are rewritten by a new physical pre-transform rule, RuntimeBloomFilterRewriteRule, restricted to InjectRuntimeFilter's exact shape (both sides wrapped in xxhash64). This keeps FilterExecTransformer and the bloom-filter aggregate native, matching the pre-change behavior; no TPC-DS plan-stability goldens change. The xxhash64 fingerprint also means DataFrame.stat.bloomFilter() (raw-column child) is never matched, so the CallerInfo.isBloomFilterStatFunction stack-walk hack is removed. Also adds GlutenBloomFilterFallbackSuite covering whole-stage fallback at thresholds 1 and 2, the SPARK-54336 literal path, JVM-mode subquery aggregation, DataFrame.stat.bloomFilter(), native-bloom-filter-disabled, and native offload of runtime bloom filters.
1 parent 5ecef3c commit a557928

7 files changed

Lines changed: 571 additions & 77 deletions

File tree

backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ object VeloxRuleApi {
5353
private def injectSpark(injector: SparkInjector): Unit = {
5454
// Inject the regular Spark rules directly.
5555
injector.injectOptimizerRule(CollectRewriteRule.apply)
56+
injector.injectOptimizerRule(BloomFilterMightContainJointRewriteRule.apply)
5657
injector.injectOptimizerRule(HLLRewriteRule.apply)
5758
injector.injectOptimizerRule(CollapseGetJsonObjectExpressionRule.apply)
5859
injector.injectOptimizerRule(RewriteCastFromArray.apply)
@@ -81,11 +82,7 @@ object VeloxRuleApi {
8182
injector.injectPreTransform(c => FallbackMultiCodegens.apply(c.session))
8283
injector.injectPreTransform(c => MergeTwoPhasesHashBaseAggregate(c.session))
8384
injector.injectPreTransform(_ => RewriteSubqueryBroadcast())
84-
injector.injectPreTransform(
85-
c =>
86-
BloomFilterMightContainJointRewriteRule.apply(
87-
c.session,
88-
c.caller.isBloomFilterStatFunction()))
85+
injector.injectPreTransform(c => RuntimeBloomFilterRewriteRule(c.session))
8986
injector.injectPreTransform(_ => EliminateRedundantGetTimestamp)
9087

9188
// Legacy: The legacy transform rule.

backends-velox/src/main/scala/org/apache/gluten/extension/BloomFilterMightContainJointRewriteRule.scala

Lines changed: 61 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -21,63 +21,71 @@ import org.apache.gluten.expression.VeloxBloomFilterMightContain
2121
import org.apache.gluten.expression.aggregate.VeloxBloomFilterAggregate
2222

2323
import org.apache.spark.sql.SparkSession
24-
import org.apache.spark.sql.catalyst.expressions.{BinaryExpression, BloomFilterMightContain, Expression}
25-
import org.apache.spark.sql.catalyst.expressions.aggregate.{BloomFilterAggregate, TypedImperativeAggregate}
24+
import org.apache.spark.sql.catalyst.expressions.{BloomFilterMightContain, Literal, ScalarSubquery}
25+
import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, BloomFilterAggregate}
26+
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
2627
import org.apache.spark.sql.catalyst.rules.Rule
27-
import org.apache.spark.sql.execution.SparkPlan
2828

29-
case class BloomFilterMightContainJointRewriteRule(
30-
spark: SparkSession,
31-
isBloomFilterStatFunction: Boolean)
32-
extends Rule[SparkPlan] {
33-
override def apply(plan: SparkPlan): SparkPlan = {
34-
if (isBloomFilterStatFunction || !GlutenConfig.get.enableNativeBloomFilter) {
29+
/**
30+
* Optimizer rule that rewrites `BloomFilterAggregate` -> `VeloxBloomFilterAggregate` and
31+
* `BloomFilterMightContain` -> `VeloxBloomFilterMightContain` at the logical plan level.
32+
*
33+
* Running as an optimizer rule ensures the substitution is captured in the `originalPlan` snapshot
34+
* that [[org.apache.gluten.extension.columnar.heuristic.ExpandFallbackPolicy]] uses when promoting
35+
* an individual stage fallback to a whole-stage AQE fallback. This guarantees that both sides of
36+
* the bloom-filter pair always produce and consume the same byte format, regardless of whether
37+
* stages fall back to JVM execution after AQE re-planning.
38+
*
39+
* This rule only covers bloom filters that are present in the plan during Spark's Operator
40+
* Optimization batch, i.e. the ones written by the user. Runtime bloom filters are injected by
41+
* `InjectRuntimeFilter`, which runs in a later batch of `SparkOptimizer`, after this rule has
42+
* already fired -- they are handled by the physical-level [[RuntimeBloomFilterRewriteRule]]
43+
* instead. The aggregate (producer) and the might-contain (consumer) are always rewritten as a
44+
* pair, or not at all, so they never end up on different serialized byte formats:
45+
* - `might_contain(ScalarSubquery(...), <non-literal>)`: rewrite both sides to their Velox forms
46+
* (version=1). This is the path that GLUTEN-12013 protects across whole-stage AQE fallback.
47+
* - `might_contain(ScalarSubquery(...), <literal>)` (as in SPARK-54336): leave both vanilla
48+
* (version=0). Rewriting only the outer side to Velox while the inner aggregate stayed vanilla
49+
* `bloom_filter_agg` is exactly what caused the `kBloomFilterV1 == version` (1 vs. 0) crash.
50+
* Leaving both vanilla also preserves vanilla's NULL-on-empty-input semantics.
51+
*
52+
* Standalone `BloomFilterAggregate` (e.g., `DataFrame.stat.bloomFilter()`) is never matched, so its
53+
* bytes stay in Spark-native format.
54+
*/
55+
case class BloomFilterMightContainJointRewriteRule(spark: SparkSession)
56+
extends Rule[LogicalPlan] {
57+
58+
override def apply(plan: LogicalPlan): LogicalPlan = {
59+
if (!GlutenConfig.get.enableNativeBloomFilter) {
3560
return plan
3661
}
37-
val out = plan.transformWithSubqueries {
38-
case p =>
39-
applyForNode(p)
40-
}
41-
out
42-
}
43-
44-
private def replaceBloomFilterAggregate[T](
45-
expr: Expression,
46-
bloomFilterAggReplacer: (
47-
Expression,
48-
Expression,
49-
Expression,
50-
Int,
51-
Int) => TypedImperativeAggregate[T]): Expression = expr match {
52-
case BloomFilterAggregate(
53-
child,
54-
estimatedNumItemsExpression,
55-
numBitsExpression,
56-
mutableAggBufferOffset,
57-
inputAggBufferOffset) =>
58-
bloomFilterAggReplacer(
59-
child,
60-
estimatedNumItemsExpression,
61-
numBitsExpression,
62-
mutableAggBufferOffset,
63-
inputAggBufferOffset)
64-
case other => other
65-
}
66-
67-
private def replaceMightContain[T](
68-
expr: Expression,
69-
mightContainReplacer: (Expression, Expression) => BinaryExpression): Expression = expr match {
70-
case BloomFilterMightContain(bloomFilterExpression, valueExpression) =>
71-
mightContainReplacer(bloomFilterExpression, valueExpression)
72-
case other => other
73-
}
74-
75-
private def applyForNode(p: SparkPlan) = {
76-
p.transformExpressions {
77-
case e =>
78-
replaceMightContain(
79-
replaceBloomFilterAggregate(e, VeloxBloomFilterAggregate.apply),
80-
VeloxBloomFilterMightContain.apply)
62+
plan.transformAllExpressions {
63+
case BloomFilterMightContain(subq: ScalarSubquery, v) if !v.isInstanceOf[Literal] =>
64+
// Non-literal value: rewrite both the outer might-contain and the inner aggregate to
65+
// Velox format so that both sides always produce/consume the same byte layout (version=1),
66+
// even when stages fall back to JVM execution via ExpandFallbackPolicy.
67+
val rewrittenPlan = subq.plan.transformAllExpressions {
68+
case ae @ AggregateExpression(b: BloomFilterAggregate, _, _, _, _) =>
69+
ae.copy(aggregateFunction = VeloxBloomFilterAggregate(
70+
b.child,
71+
b.estimatedNumItemsExpression,
72+
b.numBitsExpression,
73+
b.mutableAggBufferOffset,
74+
b.inputAggBufferOffset))
75+
}
76+
VeloxBloomFilterMightContain(subq.withNewPlan(rewrittenPlan), v)
77+
case bfmc @ BloomFilterMightContain(_: ScalarSubquery, _) =>
78+
// Literal value (SPARK-54336: `might_contain((SELECT bloom_filter_agg(col) FROM t), 0L)`).
79+
// Leave BOTH the inner aggregate and the outer might-contain as vanilla Spark expressions
80+
// so they stay on the same Spark-native (version=0) byte format, and so an empty aggregate
81+
// input still yields vanilla's NULL bloom filter. Rewriting only the outer side to Velox
82+
// (which expects version=1) while the inner aggregate stays vanilla `bloom_filter_agg` (no
83+
// Substrait mapping -> JVM -> version=0) is what caused the `kBloomFilterV1 == version`
84+
// (1 vs. 0) crash.
85+
bfmc
86+
case BloomFilterMightContain(bf, v) =>
87+
// Pre-computed literal bloom filter bytes -- rewrite to consume Velox-format bytes.
88+
VeloxBloomFilterMightContain(bf, v)
8189
}
8290
}
8391
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.gluten.config.GlutenConfig
20+
import org.apache.gluten.expression.VeloxBloomFilterMightContain
21+
import org.apache.gluten.expression.aggregate.VeloxBloomFilterAggregate
22+
23+
import org.apache.spark.sql.SparkSession
24+
import org.apache.spark.sql.catalyst.expressions.{BloomFilterMightContain, XxHash64}
25+
import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, BloomFilterAggregate}
26+
import org.apache.spark.sql.catalyst.rules.Rule
27+
import org.apache.spark.sql.execution.SparkPlan
28+
29+
/**
30+
* Physical pre-transform rule that rewrites runtime-filter bloom filters (the ones injected by
31+
* Spark's `InjectRuntimeFilter` optimizer rule) to their Velox variants so they offload natively.
32+
*
33+
* Runtime bloom filters cannot be handled by [[BloomFilterMightContainJointRewriteRule]]: that rule
34+
* is registered via `injectOptimizerRule`, which lands in Spark's Operator Optimization batch,
35+
* while `InjectRuntimeFilter` runs in a later batch of `SparkOptimizer`. The runtime-filter
36+
* expressions therefore do not exist yet when the logical rule fires, and a physical-level rewrite
37+
* (as was always done before the logical rule was introduced) is required to keep
38+
* `FilterExecTransformer` and the bloom-filter aggregate native.
39+
*
40+
* The rewrite is restricted to `InjectRuntimeFilter`'s exact expression shapes, which always wrap
41+
* the key in [[XxHash64]] on both the producer and the consumer side:
42+
* - producer: `bloom_filter_agg(xxhash64(key), ...)` -> `velox_bloom_filter_agg(...)`
43+
* - consumer: `might_contain(bf, xxhash64(key))` -> `velox_might_contain(...)`
44+
*
45+
* Because each side is identifiable on its own, both are rewritten consistently to the Velox byte
46+
* format (version=1) even when AQE compiles the bloom-filter subquery separately from the consuming
47+
* filter stage. The `XxHash64` fingerprint also guarantees the other bloom-filter populations are
48+
* never touched:
49+
* - `DataFrame.stat.bloomFilter()` builds `bloom_filter_agg(col, ...)` on the raw column (no
50+
* `XxHash64` wrapper) and deserializes the result with Spark's `BloomFilter.readFrom`, so its
51+
* bytes must stay in Spark-native format.
52+
* - User-facing `might_contain(<scalar subquery>, <value>)` pairs are already rewritten at the
53+
* logical level by [[BloomFilterMightContainJointRewriteRule]] (the GLUTEN-12013 fix), making
54+
* this rule a no-op for them.
55+
* - Literal-value pairs (SPARK-54336) contain no `XxHash64` and stay fully vanilla.
56+
*/
57+
case class RuntimeBloomFilterRewriteRule(spark: SparkSession) extends Rule[SparkPlan] {
58+
override def apply(plan: SparkPlan): SparkPlan = {
59+
if (!GlutenConfig.get.enableNativeBloomFilter) {
60+
return plan
61+
}
62+
plan.transformWithSubqueries {
63+
case p =>
64+
p.transformExpressions {
65+
case ae @ AggregateExpression(b: BloomFilterAggregate, _, _, _, _)
66+
if b.child.isInstanceOf[XxHash64] =>
67+
ae.copy(aggregateFunction = VeloxBloomFilterAggregate(
68+
b.child,
69+
b.estimatedNumItemsExpression,
70+
b.numBitsExpression,
71+
b.mutableAggBufferOffset,
72+
b.inputAggBufferOffset))
73+
case BloomFilterMightContain(bf, value: XxHash64) =>
74+
VeloxBloomFilterMightContain(bf, value)
75+
}
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)