[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
|
@zhztheplayer — quick update: CI is now fully green on this PR ✅ (62/62 checks pass; approved & mergeable). The earlier |
| RowToVeloxColumnar | ||
| WholeStageCodegen (1) | ||
| Filter [d_date_sk,d_week_seq] | ||
| Subquery #1 | ||
| ObjectHashAggregate [buf] [bloom_filter_agg(xxhash64(d_week_seq, 42), 335, 8990, 0, 0),bloomFilter,buf] | ||
| VeloxColumnarToRow | ||
| ColumnarExchange #3 | ||
| VeloxResizeBatches | ||
| RowToVeloxColumnar | ||
| ObjectHashAggregate [d_week_seq] [buf,buf] | ||
| VeloxColumnarToRow | ||
| WholeStageCodegenTransformer (1) |
There was a problem hiding this comment.
@brijrajk is this change on the final executed plan? If so, it's doesn't seem right to have the fallback?
There was a problem hiding this comment.
@zhztheplayer Yes, ObjectHashAggregate is in the final executed plan, and the fallback is intentional.
bloom_filter_agg is the attribute name, not the physical function class. It comes from resultExpressions, which is frozen when ObjectHashAggregateExec is first built with vanilla BloomFilterAggregate. Since transformAllExpressions only patches aggregateExpressions and not resultExpressions, the name stays the same regardless of any later substitution.
The physical function inside is VeloxBloomFilterAggregate, not vanilla. BloomFilterRuntimeFilterRewriteRule substitutes it via subq.withNewPlan(rewrittenPlan). eval() calls serialize(buffer) unconditionally, producing version=1 bytes that VeloxBloomFilterMightContain reads correctly.
JVM mode is unavoidable here. PlanSubqueries (pos 2) prepares the subquery before ApplyColumnarRulesAndInsertTransitions (pos 10) runs, so HeuristicTransform sees vanilla BloomFilterAggregate with no Substrait mapping and emits ObjectHashAggregateExec. By the time our rule patches it at pos 10 on the main plan, HeuristicTransform has already finished.
Substituting earlier would break SPARK-54336. If we substitute during subquery preparation, it also fires for might_contain(empty_subquery, literal). Since VeloxBloomFilterAggregate has no cardinality guard, empty input returns bytes instead of null.
Added GlutenBloomFilterFallbackSuite."GLUTEN-12013: VeloxBloomFilterAggregate in JVM subquery produces correct Velox bytes" that inspects collectWithSubqueries(executedPlan) for ObjectHashAggregateExec(VeloxBloomFilterAggregate) and verifies correct query results.
|
Run Gluten Clickhouse CI on x86 |
2 similar comments
|
Run Gluten Clickhouse CI on x86 |
|
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 |
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).