[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback - #12151
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 |
ea72ca2 to
3c58d33
Compare
|
Run Gluten Clickhouse CI on x86 |
3c58d33 to
d440534
Compare
|
Run Gluten Clickhouse CI on x86 |
|
Quick update: rebased twice more since the above to stay current with upstream (no code changes -- same commit, same test results as listed above). You may see red X's in the CI history from a couple of the reruns. I looked into each one and they're unrelated infra flakiness, not caused by this PR:
None reproduce locally and none touch the bloom-filter code path. Current CI run should be clean; happy to dig into any specific failure if it looks otherwise. |
|
@brijrajk did I miss something, why 3 rules in total are still needed for the bloom filter offloading? I was thinking we only need one single |
|
@brijrajk The bottom line is, we just ensure all Spark |
|
@zhztheplayer Good question, and the underlying insight is right: byte-format compatibility only depends on which expression class runs ( I built the literal version of the proposal to check it properly before replying: one rule, unconditionally rewriting every 1. 2. Native offload is lost for runtime bloom filters. Confirmed via plan diff: the producing aggregate ran as vanilla 3. SPARK-54336's NULL-on-empty-input semantics silently break. Not a crash -- a query that should return So I don't think we can collapse to a single late rule: fixing correctness on reversion (what Separately, unconditional matching (dropping the pairing/fingerprint check) isn't safe regardless of timing, since nothing then distinguishes a Happy to explore a narrower version, though -- e.g. dropping the literal-value special case in the logical rule so both literal and non-literal always rewrite to Velox, if we first close the NULL-on-empty-input gap in |
|
@brijrajk Thanks. Then my suggestion is to add a new API injector.injectPre(c =>
BloomFilterMightContainJointRewriteRule(
c.session,
c.caller.isBloomFilterStatFunction()))and val rewrittenPlan =
transformPlan("pre", preRules(call), inputPlan)
val suggestedPlan =
transformPlan("transform", transformRules(call), rewrittenPlan)
val finalPlan =
transformPlan(
"fallback",
fallbackPolicies(call).map(_(rewrittenPlan)),
suggestedPlan)What do you think? |
|
@zhztheplayer Took your What I implemented:
Result on Velox: it works, cleanly. Full suite passes with the collapsed single registration:
So the mechanism is sound, and it does genuinely simplify things for the runtime-filter rule specifically. Checked ClickHouse too, since this touches shared Why I'm not just pushing this into #12151: it's a |
d440534 to
7277901
Compare
|
Run Gluten Clickhouse CI on x86 |
|
Does it work for you, if you can open a refactor PR to add |
|
Yes, that works -- opened it: #12669 (tracked as GLUTEN-12668). Verified it there (322/322 plan-stability regression clean on Velox, |
…AQE fallback (#12669) ExpandFallbackPolicy's whole-stage-fallback revert target (originalPlan in HeuristicApplier.makeRule) is captured before any physical rule runs, including injectPreTransform rules. A rule registered at injectPreTransform therefore gets its rewrite stripped away whenever ExpandFallbackPolicy promotes an individual-stage fallback to a whole-stage one, requiring a second re-application at injectFinal as a workaround (see RuntimeBloomFilterRewriteRule in #12151). Adds a new injectPre hook to GlutenInjector.LegacyInjector, with its own "pre" phase in HeuristicApplier.makeRule running before "transform". fallbackPolicies now closes over the post-"pre" plan instead of the raw originalPlan, so a whole-stage revert can no longer strip away anything registered at injectPre. Purely additive: a new empty-by-default builder list threaded through HeuristicApplier's constructor. No existing backend needs to change unless it opts in; backends-clickhouse builds and links unmodified against this change. Once merged, #12151 will be rebased to register RuntimeBloomFilterRewriteRule at injectPre only, collapsing its current two registrations (injectPreTransform + injectFinal) down to one.
7277901 to
8d586d7
Compare
|
Full suite re-verified against the actual merged hook (not just the earlier prototype):
Ready for another look. |
|
Run Gluten Clickhouse CI on x86 |
|
@brijrajk Can you check whether the optimizer logical rule |
8d586d7 to
6731f58
Compare
|
Run Gluten Clickhouse CI on x86 |
…urvives whole-stage fallback `BloomFilterMightContainJointRewriteRule` rewrites `bloom_filter_agg` -> `velox_bloom_filter_agg` and `might_contain` -> `velox_might_contain` unconditionally, so producer and consumer always agree on the serialized byte format. It was registered at `injectPreTransform`, which runs after `HeuristicApplier` captures the plan that `ExpandFallbackPolicy` reverts to. When a stage fallback is promoted to a whole-stage fallback, that reversion strips the rewrite from one stage while another stage keeps it, leaving a vanilla `bloom_filter_agg` producing Spark-format bytes (4-byte big-endian version) that a `velox_might_contain` consumer reads as a Velox-format single-byte version, failing with "Unsupported BloomFilter version: 0". Registering the same rule at `injectPre` instead fixes this: `injectPre` runs before the revert-target plan is captured, so the rewrite is already baked into it and a whole-stage revert can no longer remove it. Adds `GlutenBloomFilterFallbackSuite` covering whole-stage fallback of one and both stages, runtime bloom filters injected by `InjectRuntimeFilter` (native offload plus single-stage reversion), `DataFrame.stat.bloomFilter()` keeping Spark-native bytes, the disabled-config path, and the SPARK-54336 literal-value case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6731f58 to
f24ac0e
Compare
|
@zhztheplayer Confirmed: the logical rule is not needed. I have rewritten the PR to the one-rule The whole PR is now:
Production diff is now 12 added / 5 removed lines in Worth recording why the earlier iterations pointed the other way, since it was a measurement artifact rather than a real constraint. What was being measured was the conditional rule ported to a physical hook. That variant has to reach into the scalar subquery's plan to rewrite the producer, and it cannot: The unconditional rewrite never needs that reach-in. The producer is rewritten when Gluten's rules run on the subquery's own Measured on Spark 4.0 / Scala 2.13:
Why the hook matters: Two notes on the tests:
This also relies on |
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.3. Whole-stage reversion safety for runtime bloom filters (
injectFinal)ExpandFallbackPolicy's whole-stage fallback can revert a runtime bloom filter's partialand final aggregation stages independently, since they run as separate physical operators
across a shuffle boundary. If only one side reverts, a native-Velox stage would end up
paired with a vanilla-reverted stage.
RuntimeBloomFilterRewriteRuleis now also registered atinjectFinal, which runsregardless of whether
ExpandFallbackPolicyreverted the plan. Re-applying the samerewrite there restores both sides to the Velox form after any such reversion. The rule is
idempotent for already-rewritten expressions, so this is a no-op in the common case.
This is only safe because of GLUTEN-12613 (#12614, merged):
VeloxBloomFilterAggregate'sJVM-side buffer capacity now agrees with the native
bloom_filter_aggaggregate's. Beforethat fix, a reverted stage merging with a still-native stage could silently corrupt the
filter instead of crashing on a version mismatch.
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).