[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback#12151
[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback#12151brijrajk wants to merge 2 commits into
Conversation
4a56662 to
9bf19dc
Compare
|
Run Gluten Clickhouse CI on x86 |
|
Could a maintainer please remove the CORE label? All three changed files are Velox-backend-specific (backends-velox/ and gluten-ut/spark40/) — no common core code is touched. VELOX label only is correct. Thanks! |
|
Gentle ping for a maintainer review. The Also re-raising: could a maintainer remove the CORE label? The three changed files are all Velox-backend-specific ( |
@brijrajk, thanks for the PR. Could you rebase the code to see if the CI failures go away? |
9bf19dc to
009a9a8
Compare
|
Done — rebased onto current main and force-pushed. Fresh CI triggered. |
…QE fallback
When ExpandFallbackPolicy triggers a whole-stage AQE fallback it reinstates
the plan from before HeuristicTransform (i.e. before pre-transform rewrites),
so BloomFilterMightContainJointRewriteRule's substitution of
BloomFilterMightContain -> VeloxBloomFilterMightContain is lost. If Stage 0
(bloom_filter_agg subquery) already executed natively it produced Velox-format
bytes; BloomFilterMightContain then calls BloomFilterImpl.readFrom() on those
bytes and throws:
java.io.IOException: Unexpected Bloom filter version number (16777217)
or the native assertion kBloomFilterV1 == version fires during merge.
Fix: register BloomFilterMightContainFallbackPatcher as a second fallback-policy
pass (after ExpandFallbackPolicy). It walks FallbackNode subtrees and replaces
any remaining BloomFilterMightContain inside FilterExec with
VeloxBloomFilterMightContain, so the JVM filter can read Velox-format bytes via
JNI even after falling back to the JVM execution path.
The patcher is guarded by requireBloomFilterAggMightContainJointFallback() so
it is a no-op for backends that do not require joint fallback (e.g. ClickHouse).
A regression test is added to GlutenBloomFilterAggregateQuerySuite that
reproduces the failure path:
- COLUMNAR_FILTER_ENABLED=false -> FilterExec falls back (net cost 2)
- WHOLESTAGE_FALLBACK_THRESHOLD=2 -> only filter stage falls back; agg runs
natively and emits Velox bytes
- ANSI_ENABLED=false -> prevents agg validation failure on Spark 4.0
which would raise agg-stage cost above 1
Fixes apache#12013
Generated-by: Claude Code (claude-sonnet-4-6)
009a9a8 to
3148dbe
Compare
| override def apply(plan: SparkPlan): SparkPlan = { | ||
| if (!BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback()) { | ||
| return plan | ||
| } | ||
| plan match { |
| val df = spark.sql(sqlString) | ||
| // Must not throw java.io.IOException: Unexpected Bloom filter version number (16777217) | ||
| df.collect | ||
| // All 200003 rows match the bloom filter built from the same data. | ||
| assert(df.count() == 200003L) |
|
@brijrajk, could you first check if Copilot's comments make sense? |
…; combine collect+count in test When spark.gluten.sql.native.bloomFilter=false Stage 0 (bloom_filter_agg) falls back to Spark and produces Spark-format bytes. The joint-fallback rule still wraps Stage 1 in a FallbackNode, so BloomFilterMightContainFallbackPatcher was incorrectly rewriting it to VeloxBloomFilterMightContain — causing the same IOException the patcher was introduced to fix, but from the opposite trigger (native BF off instead of whole-stage AQE fallback). Fix: add an early return in the patcher when GlutenConfig.get.enableNativeBloomFilter is false. This mirrors the existing guard already in BloomFilterMightContainJointRewriteRule. Also address Copilot review comment: replace df.collect + df.count() (two query executions) with df.collect().length (one execution) in the regression test for the same failure signal at lower cost. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for flagging this, @philo-he! Both of Copilot's comments were valid: 1. Patcher active when native bloom filter is disabled When Added a second guard: 2. Combined into |
|
@brijrajk, thanks for the update. Could you check if my following understanding is correct? Besides the |
|
@philo-he You are absolutely right. We confirmed it with a test case. How threshold and cost work
Test case confirming the failure testGluten(
"Test bloom_filter_agg whole-stage fallback when both stages fall back",
Issue12013) {
...
if (BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback()) {
// threshold=1: Stage 0's inherent transition cost of 1 meets the threshold, so
// ExpandFallbackPolicy promotes Stage 0 to a whole-stage fallback as well.
// Stage 0 runs as Spark and produces Spark-format bytes. Stage 1 also falls back.
// The patcher must NOT rewrite BloomFilterMightContain -> VeloxBloomFilterMightContain
// in this case.
withSQLConf(
GlutenConfig.COLUMNAR_FILTER_ENABLED.key -> "false",
GlutenConfig.COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD.key -> "1",
SQLConf.ANSI_ENABLED.key -> "false"
) {
val df = spark.sql(sqlString)
assert(df.collect().length == 200003L)
}
}
}Output
Proposed fix The root cause is that Do you see any concerns with this approach, or is there a cleaner way you would handle it? |
What changes are proposed in this pull request?
Fixes #12013
Root cause
When
ExpandFallbackPolicytriggers a whole-stage AQE fallback it reverts to the plan captured beforeHeuristicTransformruns (i.e. before all pre-transform rewrites). This means the substitution performed byBloomFilterMightContainJointRewriteRule— replacing vanilla Spark'sBloomFilterMightContainwithVeloxBloomFilterMightContain— is silently undone in the fallback plan.If Stage 0 (the
bloom_filter_aggsubquery) has already executed natively it has produced Velox-format bloom filter bytes. The vanillaBloomFilterMightContainin the fallen-back filter stage then callsBloomFilterImpl.readFrom()on those bytes, which throws:or causes a native assertion failure (
kBloomFilterV1 == version) during the merge phase.Fix
Register
BloomFilterMightContainFallbackPatcheras a second fallback-policy pass (afterExpandFallbackPolicy) inVeloxRuleApi. The patcher walks the subtree of everyFallbackNodeand replaces any remainingBloomFilterMightContaininsideFilterExecnodes withVeloxBloomFilterMightContain, so the JVM filter can continue to read Velox-format bytes via JNI even after the whole-stage fallback.The patcher is guarded by
requireBloomFilterAggMightContainJointFallback()so it is a no-op for backends that do not require joint fallback (e.g. ClickHouse).Files changed
BloomFilterMightContainFallbackPatcher.scala— NewRule[SparkPlan]that patches fallback plansVeloxRuleApi.scala— Registers the patcher as a second fallback-policy passGlutenBloomFilterAggregateQuerySuite.scala— Regression test for the exact failure scenarioHow was this patch tested?
A regression test
"Test bloom_filter_agg whole-stage fallback does not corrupt bloom filter bytes"was added toGlutenBloomFilterAggregateQuerySuite(taggedIssue12013).The test reproduces the precise failure path:
COLUMNAR_FILTER_ENABLED = false— forcesFilterExecto fall back (net transition cost = 2)COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD = 2— only the filter stage triggers whole-stage fallback viaExpandFallbackPolicy; thebloom_filter_aggsubquery stages (inherent cost = 1 < threshold) continue to run natively and emit Velox-format bytesANSI_ENABLED = false— Spark 4.0 enables ANSI by default, which causesObjectHashAggregateExecto fail Gluten validation and raises the agg-stage transition cost above 1; disabling ANSI keeps the agg cost at 1 so only the filter falls back as intendedWithout the fix the test fails with
IOException: Unexpected Bloom filter version number (16777217). With the fix all 200,003 rows are returned correctly.The test was run inside the
gluten-devDocker container against thegluten-ut/spark40module:Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (claude-sonnet-4-6)