[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 1 commit into
Conversation
4a56662 to
9bf19dc
Compare
|
Run Gluten Clickhouse CI on x86 |
@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. |
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? |
|
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? |
25c7fd9 to
2727774
Compare
| * This rule runs as a second fallback-policy pass, after `ExpandFallbackPolicy`, so it only acts | ||
| * when the plan is already wrapped in a `FallbackNode`. | ||
| */ | ||
| case class BloomFilterMightContainFallbackPatcher() extends Rule[SparkPlan] { |
There was a problem hiding this comment.
I don't recall why BloomFilterMightContainJointRewriteRule was made a physical rule, but can you try turning it to a logical rule anyway? So such a patcher rule can be avoided?
There was a problem hiding this comment.
Done — BloomFilterMightContainJointRewriteRule is now a Rule[LogicalPlan] registered via injectOptimizerRule, modelled after CollectRewriteRule. The patcher is gone. Running as an optimizer rule ensures both substitutions (BloomFilterAggregate → VeloxBloomFilterAggregate and BloomFilterMightContain → VeloxBloomFilterMightContain) are captured in the originalPlan snapshot before ExpandFallbackPolicy takes it, so the byte format stays consistent regardless of which stages fall back. This also fixes the threshold=1 case where Stage 0 itself falls back (the patcher would incorrectly rewrite the filter side while Stage 0 was producing Spark-format bytes).
f64edd1 to
cac891f
Compare
rdtr
left a comment
There was a problem hiding this comment.
could CallerInfo.isBloomFilterStatFunction() and inBloomFilterStatFunctionCall() be removed now with this PR?
| // from the original vanilla Spark plan which contains BloomFilterMightContain (not the Velox | ||
| // variant). If Stage 0 (bloom_filter_agg subquery) already ran natively it produced Velox- | ||
| // format bytes, which BloomFilterImpl.readFrom() cannot deserialize. BloomFilterMightContain- | ||
| // FallbackPatcher patches the fallback plan to use VeloxBloomFilterMightContain so Stage 1 |
There was a problem hiding this comment.
I think Patcher is now gone so this comment is outdated?
There was a problem hiding this comment.
Fixed — updated the comment to describe the optimizer rule approach instead.
cac891f to
59c6c50
Compare
|
Run Gluten Clickhouse CI on x86 |
1 similar comment
|
Run Gluten Clickhouse CI on x86 |
7005063 to
6b22e3d
Compare
|
Run Gluten Clickhouse CI on x86 |
|
Hi @zhztheplayer — just following up on your June 29 question about the |
|
The fallbacks will cause serious performance issues, as If this is intentional, would you mean previously Spark 4.0 / 4.1 + bloom filter was broken? Though they were fine in our local tests. Did I miss something here? |
|
@zhztheplayer -- you are absolutely right, and thank you for pushing on this. The Root cause After the rule was moved to The rule had two cases: // Case 1 -- matched only Attribute values (user-facing bloom filter)
case BloomFilterMightContain(subq: ScalarSubquery, v: Attribute) => ...rewrite to Velox...
// Case 2 -- catch-all, intended only for SPARK-54336 literals
case bfmc @ BloomFilterMightContain(_: ScalarSubquery, _) => bfmc // leave vanillaBecause My earlier comment that the rule does not observe runtime-filter expressions was incorrect; I have since verified the batch ordering. Fix Change the first case guard from // Covers user-facing (Attribute) AND runtime-filter (xxhash64) -- rewrite both to Velox
case BloomFilterMightContain(subq: ScalarSubquery, v) if !v.isInstanceOf[Literal] =>
...rewrite inner bloom_filter_agg to VeloxBloomFilterAggregate and wrap in VeloxBloomFilterMightContain...
// Only Literals reach here (SPARK-54336: might_contain(subq, 0L)) -- leave both vanilla
case bfmc @ BloomFilterMightContain(_: ScalarSubquery, _) => bfmcVerification
Also included in this update: added |
3bd6e21 to
d378349
Compare
|
Run Gluten Clickhouse CI on x86 |
1 similar comment
|
Run Gluten Clickhouse CI on x86 |
d378349 to
f8ad4ce
Compare
|
Run Gluten Clickhouse CI on x86 |
|
@brijrajk, thanks for the update. The ClickHouse CI reported some errors. Please check them using the following account, in case you weren't aware. gluten/docs/get-started/ClickHouse.md Line 651 in 5d9bced |
f8ad4ce to
08cd903
Compare
|
Thanks @philo-he for pointing out the ClickHouse CI. I wasn't aware of it and wouldn't have caught this without your heads-up. The failure was a compile error in Also rebased onto the latest main, which included a conflict with |
|
Run Gluten Clickhouse CI on x86 |
08cd903 to
6fb6250
Compare
|
Run Gluten Clickhouse CI on x86 |
|
@philo-he CI is now fully green including the ClickHouse CI. Thanks again for the heads-up. |
|
@brijrajk, thank you for your continued efforts. I will review this PR again. |
6fb6250 to
a557928
Compare
|
Run Gluten Clickhouse CI on x86 |
|
@zhztheplayer @philo-he you were both right to push on the approved-plan changes, and I owe you a correction before anything else. Retraction. My June 30 comment claimed the q59 fallback was intentional and that a "BloomFilterRuntimeFilterRewriteRule" patched the subquery aggregate. That analysis was wrong: no such mechanism existed on the branch, and the regenerated goldens themselves showed vanilla Root cause (verified against Spark sources). Fix (pushed). The rewrite is now split by where the expressions become visible:
Goldens. With runtime filters native again, no TPC-DS plans change at all. The golden-regeneration commit is dropped and the PR is back to a single commit, rebased onto current main. That directly answers the "why do we need to update them" question: we should not have. Tests (all green locally, Spark 4.0 Velox backend).
|
…QE 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.
a557928 to
565013e
Compare
|
Run Gluten Clickhouse CI on x86 |
| * this rule a no-op for them. | ||
| * - Literal-value pairs (SPARK-54336) contain no `XxHash64` and stay fully vanilla. | ||
| */ | ||
| case class RuntimeBloomFilterRewriteRule(spark: SparkSession) extends Rule[SparkPlan] { |
There was a problem hiding this comment.
@brijrajk, thank you so much for the update.
For runtime bloom filters, could you clarify what happens if the producer and consumer become inconsistent, i.e., one is offloaded to Velox while the other falls back due to the fallback policy?
There was a problem hiding this comment.
@philo-he Good question. Verified at three levels: code inspection, Spark-source trace, and runtime probes in a container. Summary:
1. Operator-level mixes are safe by construction. This rule rewrites the expressions at pre-transform. Wherever the operators land (native or JVM), the expressions stay VeloxBloomFilterAggregate / VeloxBloomFilterMightContain, which run on the JVM via JNI with the same version=1 byte format. Byte format travels with the expression class, not the operator.
| Probe | Non-default configs | Result |
|---|---|---|
| A | none | SUCCESS, correct rows |
| B | columnar.filter.enabled=false (consumer operator on JVM) |
SUCCESS |
| C | columnar.hashagg.enabled=false (producer operator on JVM) |
SUCCESS |
2. Full disclosure: whole-stage fallback reversion is a real gap. HeuristicApplier captures originalPlan before pre-transform, so ExpandFallbackPolicy reversion strips the rewrite from that stage only, while the subquery is prepared independently (PlanSubqueries). With the fallback thresholds enabled, formats can split in both directions:
| Probe | Non-default configs | Result |
|---|---|---|
| D | columnar.filter.enabled=false + wholeStage.fallback.threshold=1 |
CRASH: Velox (1 vs. 0) Unsupported BloomFilter version: 0 |
| E | wholeStage.fallback.threshold=1 only |
CRASH: IOException: Unexpected Bloom filter version number (16777218) |
3. Caveats bounding the gap:
- Unreachable with default configs (both thresholds default to
-1). - Pre-existing: I reran all five probes with main's bloom-filter code in the same environment; main crashes identically on D and E. This PR neither introduces nor widens it.
- Fails loudly (version-check errors on both sides); never a silent wrong result.
- The user-facing path is already reversion-safe in this PR: the logical rule bakes the rewrite into
originalPlanitself (that is the GLUTEN-12013 fix, proven by the threshold=1/2 tests).
4. Why not fixable the same way here: it needs a logical rewrite after InjectRuntimeFilter, and Spark's extension API has no hook between that batch and physical planning. Re-patching fallen-back plans is the "patcher" we removed earlier at review feedback.
5. Candidate fixes (follow-up):
- Propose an upstream Spark extension point for post-
InjectRuntimeFilterlogical rules, then move the runtime-filter rewrite there. - Or a narrowly-guarded joint re-rewrite in the
finalrule phase (applies to fallen-back plans too), restricted to thexxhash64shape on both sides.
@philo-he @zhztheplayer what do you think the next steps should be for resolving this gap: a follow-up issue (I can file it with the probe D/E reproduction), or do you consider it in scope for this PR?
There was a problem hiding this comment.
@brijrajk, thanks for the investigation.
A cleaner fix might be a Spark extension API that lets our logical rule run after InjectRuntimeFilter, so the rewrite lands in the originalPlan snapshot (which is the actual plan in stage fallback situation). Not sure upstream would accept it.
Alternatively, we could change the fallback policy to block whole-stage fallback when a stage contains bloom-filter. That keeps both producer and consumer native and consistent, at the cost of overriding the fallback decision.
@zhztheplayer, do you have any comments?
There was a problem hiding this comment.
Alternatively, we could change the fallback policy to block whole-stage fallback when a stage contains bloom-filter.
Is there any possible risk, if we always replace bloomfilter_agg and might_contain with velox_bloomfilter_agg and velox_might_contain in the Gluten's physical optimization phase, despite the whole-stage fallback status? If it's doable, perhaps we can directly move the existing physical rule BloomFilterMightContainJointRewriteRule to injectFinal, which is executed after wholestage fallback?
@brijrajk by the way, in the current PR status, why BloomFilterMightContainJointRewriteRule is still kept as a logical rule given it's not a valid injection point, did I miss something?
There was a problem hiding this comment.
@zhztheplayer @philo-he I prototyped the injectFinal idea and probed it at runtime instead of answering from theory. Findings below, including one correction to my own Jul 14 comment.
injectFinal PoC results (zhztheplayer's Q1)
Implemented as keep-preTransform-plus-add-final (a pure move would regress offload: validation needs the Velox expression before HeuristicTransform). Same xxhash64 guards.
| Probe | Configs | Before PoC | With PoC |
|---|---|---|---|
| D | filter=false + wholeStage.fallback.threshold=1 |
CRASH (velox, 1 vs. 0) | 10 rows, correct |
| E | wholeStage.fallback.threshold=1 only |
CRASH (IOException 16777218) | 6 of 10 rows, silent wrong result |
| Guards (stat / SPARK-54336 / native-off) | pass | pass |
Probe E is deterministic (same missing keys across reruns). A bloom filter cannot produce false negatives, so the filter itself was corrupt. Strictly worse than the crash, so I have not pushed it.
Root cause: capacity divergence (new finding)
Probe E's plan shows a phase split: native partial (FlushableHashAggregateExecTransformer[VeloxBloomFilterAggregate:Partial]) feeding a reverted JVM final (ObjectHashAggregateExec[VeloxBloomFilterAggregate:Final]). Same byte format, but the two engines size the buffer differently.
estimatedNumItems/numBits are identical on both engines (1000000/8388608, Spark's plain defaults; my test table has no ANALYZE stats). The divergence is not a different capacity source -- it's two different sizing formulas over the same two literals: JVM's createAggregationBuffer() sizes from estimatedNumItems alone (VeloxBloomFilter.empty(1000000) -> 16,777,216 bits), native's BloomFilterAggAggregate.cpp sizes from numBits alone (capacity_ = numBits/16 -> 8,388,608 bits). Exactly 2x, deterministic, independent of stats or config. Velox's BloomFilter::merge guards the size match with VELOX_DCHECK_EQ (compiled out in release) then ORs the full range: silent corruption.
Correction to my Jul 14 comment: probe E's original crash was this same phase split (vanilla final agg reading native partial buffers), not the consumer reverting; the consumer stayed a native FilterExecTransformer throughout.
Why the logical rule stays (zhztheplayer's Q2)
- The literal-vs-non-literal pair decision (SPARK-54336 vs GLUTEN-12013) needs whole-plan visibility; after
PlanSubqueries, a producer-sidebloom_filter_agg(col)alone is ambiguous, and there is noxxhash64-style fingerprint to disambiguate. - It bakes the rewrite into
originalPlan, so the user-facing population is reversion-safe with zero patching (proven by the threshold=1/2 tests).
The three options (incl. philo-he's proposals)
| Option | Verdict | Notes |
|---|---|---|
injectFinal re-rewrite |
Safe only after capacity alignment | Fix: JVM buffer sized the same way as native (e.g. from numBits), or adopt incoming size on first merge (bits_.empty() branch supports it). Also propose to Velox: promote the DCHECK to a user check. |
| Fallback-policy block (philo-he) | Most robust short-term | Only option preventing both split kinds (producer/consumer AND partial/final); no capacity prerequisite. Layering solvable: pass a bloom predicate from VeloxRuleApi where ExpandFallbackPolicy is constructed. Cost: overrides user's fallback intent. |
| Upstream Spark extension point (philo-he) | Best long-term | Deletes the physical rule; but does not cover the phase split by itself, so capacity alignment is needed regardless. Happy to draft the SPARK JIRA. |
Proposed sequencing
- Merge this PR as-is: fixes GLUTEN-12013 + SPARK-54336, restores native runtime filters, zero golden changes; the residual gap is default-off, fails loudly, and is byte-identical to main (same probes run against main's bloom code).
- Follow-up 1: capacity alignment + a phase-split regression test (prerequisite).
- Follow-up 2: close the reversion gap on top, via
injectFinalor the policy predicate, your preference. - Long-term: SPARK JIRA for the extension point.
Happy to file the follow-ups with the probe reproductions. Does this sequencing work for you both?
There was a problem hiding this comment.
@zhztheplayer You're right, injectFinal does always execute, and it does correctly rewrite the expression. That part works. The problem is one level deeper: the rewrite fixes byte format, not bloom capacity.
I re-verified this by building and running in the container with added diagnostics rather than guessing. estimatedNumItems/numBits are identical on both engines (1000000/8388608 -- Spark's plain defaults, since my test table has no ANALYZE stats). There is no source divergence. The real bug: JVM and native compute the buffer size from the same two literals using different formulas, each using only one of the two arguments.
| Path | Formula | Result for (items=1000000, bits=8388608) |
|---|---|---|
JVM VeloxBloomFilterAggregate.createAggregationBuffer() (line 102) |
VeloxBloomFilter.empty(estimatedNumItems) -> Velox reset(1000000) -> nextPow2(1000000)/4 words. Ignores numBits entirely. |
16,777,216 bits |
Native BloomFilterAggAggregate.cpp |
capacity_ = numBits/16 -> reset(capacity_). Ignores raw item count once numBits is present. |
8,388,608 bits |
Exactly 2x, deterministic, and independent of table stats or session config.
| Probe | Partial stage | Final stage | Sizes match? | Result |
|---|---|---|---|---|
| A baseline | native | native | yes | 10/10 |
D filter=false + threshold=1 |
JVM | JVM | yes (both JVM formula) | 10/10 |
E threshold=1 only |
native | JVM | no | 6/10 |
Probe D reverts the whole subquery stage together, so both ends use the same formula and stay consistent. Probe E reverts only the final-aggregation stage while partial stays native -- native partial sets bits modulo an 8,388,608-bit array; the JVM final buffer (sized for 16,777,216 bits) merges those bits into the first half of a differently-sized array (unchecked, per the DCHECK noted earlier), and later queries hash modulo the larger array, so previously-set bits land at different positions than where they were inserted.
Bottom line unchanged: any fix needs the JVM aggregate's buffer sizing reconciled with the native formula (e.g. size from numBits the same way, or adopt the incoming filter's size on first merge), independent of whether we land on injectFinal, the fallback-policy block, or the upstream extension point.
There was a problem hiding this comment.
@brijrajk Thanks for the summary.
So can we just fix the JVM buffer creation code to align with the native code? Then keep one single rule in the planner?
There was a problem hiding this comment.
@zhztheplayer Short answer: the capacity fix stands on its own and is worth doing regardless. Consolidating to one rule doesn't work as a simple move though. I went ahead and built it to check.
What I built and ran
Capacity fix plus one unified rule, registered at both preTransform and injectFinal, with the logical rule dropped entirely. Ran it against the existing regression suite (GlutenBloomFilterFallbackSuite).
What failed
4 of the 7 tests crash with the same (1 vs. 0) version mismatch this PR exists to fix:
| Test | Needs producer/consumer coordination? | Result |
|---|---|---|
| threshold=2 fallback | Yes | FAILED, (1 vs. 0) |
| threshold=1 fallback | Yes | FAILED, same crash |
| JVM-mode subquery | Yes | FAILED, same crash |
| SPARK-54336 literal | Yes | FAILED, same crash |
| stat.bloomFilter | No | passed |
| native.bloomFilter=false | No | passed |
| runtime-filter-native | No, xxhash64 alone is enough signal | passed |
The split is clean: anything needing "check the consumer's value, then update the producer to match" fails. Anything that doesn't need that coordination passes.
Why it fails
I logged the plan before and after collect() on the threshold=2 test to see what was actually happening:
| Filter (consumer) | Subquery aggregate (producer) | |
|---|---|---|
| Before collect() | vanilla | vanilla |
| After collect() | rewritten correctly | still vanilla |
Same rule, same match arm, same subq.withNewPlan(...) call. Only half of it takes effect at runtime.
Here's why: InsertAdaptiveSparkPlan.buildSubqueryMap compiles and caches each subquery's physical plan once, keyed by exprId, before Gluten's rule ever runs. A physical rule that reaches into the subquery and rewrites it produces a perfectly valid new object, but the subquery executes off that earlier cached copy, not the new one. The consumer rewrite works because it's just a plain expression swap sitting in the outer plan. The producer rewrite needs the cache itself to change, and there's no way to reach it from a rule running this late.
That also explains why the runtime-filter rule was never affected: it doesn't reach into the subquery manually at all, it matches the aggregate expression wherever the framework's own traversal already visits it, so it's wired correctly by construction. And it explains why the logical rule works: it rewrites the subquery's logical plan before the cache is even built, so the cache is already correct by the time AQE captures it.
Suggestion
Keep the two rules as they are for this PR. File the capacity fix as its own follow-up since it doesn't depend on any of this. And treat "can a physical rule ever be made reversion-safe for the coordinated case" as a separate question, one the Spark extension point idea might actually answer better than anything we can do rule-side.
There was a problem hiding this comment.
From my understanding, if we can fix the capacity issue first, then the error you met on native partial stage + JVM final stage during experimenting injectFinal can be fixed?
If yes can you first open a PR to align the capacity, which can be merged first, then in this PR you can update to an injectFinal solution? Does that work for you, or did I miss something there?
There was a problem hiding this comment.
@zhztheplayer Yes, confirmed. I built exactly this (capacity fix, plus registering the existing RuntimeBloomFilterRewriteRule at injectFinal too, nothing else changed) and it works cleanly.
Full existing regression suite (GlutenBloomFilterFallbackSuite): 7/7 passed, since the logical rule is untouched.
The adversarial probe that was silently wrong before (native partial stage + JVM final stage) now returns correct results:
| Probe | Before capacity fix | After capacity fix + injectFinal |
|---|---|---|
| Whole-stage reversion, both sides JVM | 10/10 correct | 10/10 correct |
| Partial-only reversion, native partial + JVM final | silently wrong, 6/10 | 10/10 correct |
This works because RuntimeBloomFilterRewriteRule never reaches into the subquery's plan and rebuilds it. It matches the producer and the consumer independently, each identified purely by the xxhash64 wrapper, and lets Spark's own traversal wire the rewrite in wherever it lands. That's different from what broke in the full-consolidation version I tried earlier, where the rule had to reach into the subquery manually to coordinate the literal check, and that manual rewrite didn't survive to execution.
I opened the capacity fix as its own PR: #12614 (issue #12613). Verified there too: reverting just the fix while keeping the new regression test makes exactly that one test fail, with everything else in the suite unaffected, so the test is genuinely pinned to the bug.
Once #12614 merges, I will update this PR to add the injectFinal registration on top.
What changes are proposed in this pull request?
Fixes #12013, and the
SPARK-54336regression that the issue's query shape exposes.Background
BloomFilterMightContainJointRewriteRulerewrites a bloom-filter producer(
bloom_filter_agg) and its consumer (might_contain) to their Velox variants so Veloxevaluates them natively. Previously it was a physical pre-transform rule; this PR splits the
rewrite into two rules according to where the expressions become visible in the optimizer
pipeline.
1. User-facing bloom filters: logical rule (the GLUTEN-12013 fix)
BloomFilterMightContainJointRewriteRuleis now aRule[LogicalPlan]registered viainjectOptimizerRule, which lands in Spark's Operator Optimization batch. Running thereensures the substitution is baked into the
originalPlansnapshot thatExpandFallbackPolicycaptures when it promotes an individual-stage fallback to awhole-stage AQE fallback, so both sides keep the same serialized byte format (version=1)
even if a stage reverts to JVM execution. That is the original GLUTEN-12013 crash
(
java.io.IOException: Unexpected Bloom filter version number).The producer and consumer are always rewritten as a pair, or not at all:
might_contain(ScalarSubquery(...), <non-literal>): rewrite both sides to Velox forms.might_contain(ScalarSubquery(...), <literal>)(theSPARK-54336shape,might_contain((SELECT bloom_filter_agg(col) FROM t), 0L)): leave BOTH sides vanilla.Rewriting only the outer side to Velox (version=1) while the inner vanilla
bloom_filter_agghas no Substrait mapping and emits version=0 bytes is what caused thekBloomFilterV1 == version(1 vs. 0) crash. Leaving both vanilla also preservesvanilla's NULL-on-empty-input semantics.
2. Runtime bloom filters: physical rule
injectOptimizerRuleregisters intoextendedOperatorOptimizationRules, which run insidethe Operator Optimization batch, while
InjectRuntimeFilterruns in a later batch ofSparkOptimizer. The logical rule therefore never sees runtime-filter bloom expressions:they do not exist yet when it fires. Without a rewrite they have no Substrait mapping, and
both the consuming
FilterExecand the producing aggregate would fall back to the JVM withR2C/C2R transitions (a serious performance regression on TPC-DS q59 and other
runtime-filter queries, caught by reviewers on an earlier revision of this PR).
A new physical pre-transform rule,
RuntimeBloomFilterRewriteRule, handles them. It isrestricted to
InjectRuntimeFilter's exact expression shape, which always wraps the key inxxhash64(...)on both sides:bloom_filter_agg(xxhash64(key), ...)tovelox_bloom_filter_agg(...)might_contain(bf, xxhash64(key))tovelox_might_contain(...)Each side is identifiable on its own, so the rewrite stays consistent even when AQE
compiles the bloom-filter subquery separately from the consuming filter stage. This keeps
FilterExecTransformerand the bloom-filter aggregate native, matching the behavior beforethis PR; no TPC-DS plan-stability goldens change.
The
xxhash64fingerprint also meansDataFrame.stat.bloomFilter()(which buildsbloom_filter_aggon the raw column and deserializes the bytes with Spark'sBloomFilter.readFrom) is structurally never matched, so theCallerInfo.isBloomFilterStatFunctionstack-walk hack is removed.How was this patch tested?
New
GlutenBloomFilterFallbackSuiteinbackends-veloxcovering:SPARK-54336literal-value path (both sides stay vanilla)VeloxBloomFilterAggregateinsideObjectHashAggregateExec)DataFrame.stat.bloomFilter()staying on Spark-native bytesspark.gluten.sql.native.bloomFilter=falseearly exitFilterExecTransformerwithvelox_might_containand a
VeloxBloomFilterAggregateproducerAll green locally (Spark 4.0, Velox backend), plus
GlutenBloomFilterAggregateQuerySuite(14/14, including
SPARK-54336) and all 7 TPC-DS/TPC-H plan-stability suites against theunchanged goldens (322/322).