Skip to content

[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback#12151

Open
brijrajk wants to merge 1 commit into
apache:mainfrom
brijrajk:fix/12013-bloom-filter-stage-fallback
Open

[GLUTEN-12013][VL] Fix bloom-filter bytes corruption on whole-stage AQE fallback#12151
brijrajk wants to merge 1 commit into
apache:mainfrom
brijrajk:fix/12013-bloom-filter-stage-fallback

Conversation

@brijrajk

@brijrajk brijrajk commented May 27, 2026

Copy link
Copy Markdown
Contributor

What changes are proposed in this pull request?

Fixes #12013, and the SPARK-54336 regression that the issue's query shape exposes.

Background

BloomFilterMightContainJointRewriteRule rewrites a bloom-filter producer
(bloom_filter_agg) and its consumer (might_contain) to their Velox variants so Velox
evaluates 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)

BloomFilterMightContainJointRewriteRule is now a Rule[LogicalPlan] registered via
injectOptimizerRule, which lands in Spark's Operator Optimization batch. Running there
ensures the substitution is baked into the originalPlan snapshot that
ExpandFallbackPolicy captures when it promotes an individual-stage fallback to a
whole-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>) (the SPARK-54336 shape,
    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_agg has no Substrait mapping and emits version=0 bytes is what caused the
    kBloomFilterV1 == version (1 vs. 0) crash. Leaving both vanilla also preserves
    vanilla's NULL-on-empty-input semantics.

2. Runtime bloom filters: physical rule

injectOptimizerRule registers into extendedOperatorOptimizationRules, which run inside
the Operator Optimization batch, while InjectRuntimeFilter runs in a later batch of
SparkOptimizer. 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 FilterExec and the producing aggregate would fall back to the JVM with
R2C/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 is
restricted to InjectRuntimeFilter's exact expression shape, which always wraps the key in
xxhash64(...) on both sides:

  • producer: bloom_filter_agg(xxhash64(key), ...) to velox_bloom_filter_agg(...)
  • consumer: might_contain(bf, xxhash64(key)) to velox_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
FilterExecTransformer and the bloom-filter aggregate native, matching the behavior before
this PR; no TPC-DS plan-stability goldens change.

The xxhash64 fingerprint also means DataFrame.stat.bloomFilter() (which builds
bloom_filter_agg on the raw column and deserializes the bytes with Spark's
BloomFilter.readFrom) is structurally never matched, so the
CallerInfo.isBloomFilterStatFunction stack-walk hack is removed.

How was this patch tested?

New GlutenBloomFilterFallbackSuite in backends-velox covering:

  • whole-stage AQE fallback at thresholds 1 and 2 (the GLUTEN-12013 scenarios)
  • the SPARK-54336 literal-value path (both sides stay vanilla)
  • JVM-mode subquery aggregation (VeloxBloomFilterAggregate inside ObjectHashAggregateExec)
  • DataFrame.stat.bloomFilter() staying on Spark-native bytes
  • spark.gluten.sql.native.bloomFilter=false early exit
  • runtime bloom filters keeping a native FilterExecTransformer with velox_might_contain
    and a VeloxBloomFilterAggregate producer

All 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 the
unchanged goldens (322/322).

@github-actions github-actions Bot added CORE works for Gluten Core VELOX labels May 27, 2026
@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch 2 times, most recently from 4a56662 to 9bf19dc Compare May 27, 2026 11:38
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@philo-he

Copy link
Copy Markdown
Member

Gentle ping for a maintainer review. The link-referenced-issues CI check that initially failed has since re-run successfully — all checks are green.

Also re-raising: could a maintainer remove the CORE label? The three changed files are all Velox-backend-specific (backends-velox/ and gluten-ut/spark40/) — no common core code is touched, so VELOX label only is correct.

@brijrajk, thanks for the PR. Could you rebase the code to see if the CI failures go away?

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from 9bf19dc to 009a9a8 Compare June 11, 2026 05:38
@brijrajk

Copy link
Copy Markdown
Contributor Author

Done — rebased onto current main and force-pushed. Fresh CI triggered.

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from 009a9a8 to 3148dbe Compare June 11, 2026 05:50
@philo-he
philo-he requested a review from Copilot June 11, 2026 16:30
@philo-he philo-he removed the CORE works for Gluten Core label Jun 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +44 to +48
override def apply(plan: SparkPlan): SparkPlan = {
if (!BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback()) {
return plan
}
plan match {
Comment on lines +173 to +177
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)
@philo-he

Copy link
Copy Markdown
Member

@brijrajk, could you first check if Copilot's comments make sense?

@github-actions github-actions Bot added the CORE works for Gluten Core label Jun 11, 2026
@brijrajk

Copy link
Copy Markdown
Contributor Author

Thanks for flagging this, @philo-he!

Both of Copilot's comments were valid:

1. Patcher active when native bloom filter is disabled

When spark.gluten.sql.native.bloomFilter=false, Stage 0 falls back to Spark and produces Spark-format bytes. The joint-fallback rule still wraps Stage 1 in a FallbackNode, so the patcher was incorrectly rewriting it to VeloxBloomFilterMightContain — which would cause the same IOException the patcher was introduced to fix, just from the opposite trigger.

Added a second guard: if (!GlutenConfig.get.enableNativeBloomFilter) return plan. This mirrors the existing guard already in BloomFilterMightContainJointRewriteRule.

2. df.collect + df.count() runs the query twice

Combined into assert(df.collect().length == 200003L) — single execution, same failure signal if the IOException is thrown.

@philo-he

Copy link
Copy Markdown
Member

@brijrajk, thanks for the update. Could you check if my following understanding is correct?

Besides the spark.gluten.sql.native.bloomFilter=false setting, which makes the bloom filter fall back in stage 0, there's another case: the fallback policy can also cause it to fall back. In that case, if we rely solely on checking that config, could it lead to an incompatibility issue in stage 1?

@brijrajk

Copy link
Copy Markdown
Contributor Author

@philo-he You are absolutely right. We confirmed it with a test case.

How threshold and cost work

ExpandFallbackPolicy counts the number of columnar↔row conversion boundaries inside a stage. If that count (cost) meets COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD, the entire stage is wrapped in a FallbackNode and runs as plain Spark.

Scenario Threshold Stage 0 cost Stage 1 cost Outcome
Original fix (PR as-is) 2 1 → native ✓ 2 → whole-stage fallback Stage 0 Velox bytes, Stage 1 JVM — patcher correct
Your scenario 1 1 → whole-stage fallback ≥ 1 → whole-stage fallback Stage 0 Spark bytes, Stage 1 JVM — patcher misfires

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

- Gluten - Test bloom_filter_agg whole-stage fallback when both stages fall back *** FAILED ***
  org.apache.spark.SparkException: Job aborted due to stage failure: Task 0 in stage 7.0 failed 1 times,
  most recent failure: Lost task 0.0 in stage 7.0: org.apache.gluten.exception.GlutenException:
  Exception: VeloxUserError
  Error Source: USER
  Error Code: INVALID_ARGUMENT
  Reason: (1 vs. 0)
  Retriable: False
  Expression: kBloomFilterV1 == version
  Function: mayContain
  File: velox/common/base/BloomFilter.h
  Line: 70

    at org.apache.gluten.utils.VeloxBloomFilterJniWrapper.mightContainLongOnSerializedBloom(Native Method)
    at org.apache.gluten.utils.VeloxBloomFilter.mightContainLongOnSerializedBloom(VeloxBloomFilter.java:163)
    ...

Tests: succeeded 1, failed 1

kBloomFilterV1 == version failing with (1 vs. 0) is the exact version-byte mismatch: Velox's reader expected its own format (1) but got Spark's format (0).

Proposed fix

The root cause is that enableNativeBloomFilter answers "is native bloom filter on in config?" but the right question is "did Stage 0 actually run natively?" The fix is to make the guard structural: inside patchBloomFilterMightContain, before rewriting, inspect the physical plan referenced by bloomFilterExpression. If Stage 0's plan is itself a FallbackNode, it will produce Spark-format bytes and Stage 1 must be left with the vanilla BloomFilterMightContain.

Do you see any concerns with this approach, or is there a cleaner way you would handle it?

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch 3 times, most recently from 25c7fd9 to 2727774 Compare June 19, 2026 04:23

@zhztheplayer zhztheplayer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brijrajk thanks.

* 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] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (BloomFilterAggregateVeloxBloomFilterAggregate and BloomFilterMightContainVeloxBloomFilterMightContain) 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).

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch 2 times, most recently from f64edd1 to cac891f Compare June 20, 2026 02:38

@rdtr rdtr left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Patcher is now gone so this comment is outdated?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated the comment to describe the optimizer rule approach instead.

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from cac891f to 59c6c50 Compare June 20, 2026 02:53
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

1 similar comment
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from 7005063 to 6b22e3d Compare July 1, 2026 09:59
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk

brijrajk commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @zhztheplayer — just following up on your June 29 question about the ObjectHashAggregate fallback in the q59 plan stability golden. To summarise: the fallback is intentional — PlanSubqueries (pos 2) prepares the subquery before ApplyColumnarRulesAndInsertTransitions (pos 10) runs, so HeuristicTransform sees vanilla BloomFilterAggregate and emits ObjectHashAggregateExec. BloomFilterRuntimeFilterRewriteRule then patches the inner function to VeloxBloomFilterAggregate at pos 10, so it produces version=1 bytes correctly. Substituting earlier would break the SPARK-54336 empty-input NULL semantics. A regression test covering this exact scenario was added in GlutenBloomFilterFallbackSuite. Hope this helps address your concern!

@zhztheplayer

Copy link
Copy Markdown
Member

@brijrajk

The fallbacks will cause serious performance issues, as FilterExec along with the C2R / R2C now replaces previous FilterExecTransformer .

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?

@brijrajk

brijrajk commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@zhztheplayer -- you are absolutely right, and thank you for pushing on this.

The FilterExec fallback is not intentional; it is a regression introduced by an overly-broad pattern match in the logical rule. Here is what went wrong and what the fix does:

Root cause

After the rule was moved to injectOptimizerRule (Operator Optimization batch, offset 487), InjectRuntimeFilter (offset 130) runs before it. So when our rule fires, runtime-filter bloom filter expressions of the form might_contain(ScalarSubquery, xxhash64(col, seed)) are already present in the plan.

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 vanilla

Because _ in case 2 matches any expression, including xxhash64(col, seed), runtime-filter nodes fell through to the vanilla path. The FilterExecTransformer transformation then had nothing to work with and fell back to JVM FilterExec with C2R/R2C conversions.

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 v: Attribute to !v.isInstanceOf[Literal]:

// 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, _) => bfmc

Verification

  • GlutenTPCDSModifiedPlanStabilitySuite and GlutenTPCDSV1_4_PlanStabilitySuite (Spark 4.0): q59 now shows FilterExecTransformer (native), matching the committed golden exactly. No golden regeneration needed since the committed golden was already in the correct native state.
  • GlutenInjectRuntimeFilterSuite (Spark 4.0): 13/13 tests pass.
  • GlutenBloomFilterFallbackSuite: all 6 tests pass, including SPARK-54336.

Also included in this update: added gluten-delta as a test dependency under a new delta Maven profile in gluten-ut/test/pom.xml and gluten-ut/spark40/pom.xml. Without it on the classpath, VeloxDeltaComponent throws NoClassDefFoundError during GlutenSessionExtensions.apply(), which Spark silently catches, resulting in no Gluten optimizer rules being injected and tests silently running as vanilla Spark. The dependency is gated on -Pdelta (not unconditional in backends-velox) so CI jobs that do not activate -Pdelta are unaffected.

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch 2 times, most recently from 3bd6e21 to d378349 Compare July 7, 2026 18:45
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

1 similar comment
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from d378349 to f8ad4ce Compare July 8, 2026 03:40
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@philo-he

philo-he commented Jul 9, 2026

Copy link
Copy Markdown
Member

@brijrajk, thanks for the update. The ClickHouse CI reported some errors. Please check them using the following account, in case you weren't aware.

public read-only account:gluten/hN2xX3uQ4m

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from f8ad4ce to 08cd903 Compare July 9, 2026 06:41
@brijrajk

brijrajk commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

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 GlutenBloomFilterFallbackSuite: it imports VeloxBloomFilterAggregate (a Velox-only type), but the file lived in gluten-ut/test which is compiled by the ClickHouse CI as well. Fixed by moving the suite to backends-velox/src/test/scala/ where all Velox-specific tests belong.

Also rebased onto the latest main, which included a conflict with CallerInfo.scala (commit #12477 optimized inBloomFilterStatFunctionCall that our PR removes). Resolved by keeping our removal; the new rule no longer needs the caller-stack check at all.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from 08cd903 to 6fb6250 Compare July 9, 2026 06:47
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk

brijrajk commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@philo-he CI is now fully green including the ClickHouse CI. Thanks again for the heads-up.

@philo-he

philo-he commented Jul 10, 2026

Copy link
Copy Markdown
Member

@brijrajk, thank you for your continued efforts. I will review this PR again.
Just a quick glance. I noticed many changes to the approved plans, and I'm still not sure why we need to update them. Also, some filter operators fall back to vanilla Spark according to those changes.

@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from 6fb6250 to a557928 Compare July 10, 2026 19:06
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@brijrajk

Copy link
Copy Markdown
Contributor Author

@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 might_contain / bloom_filter_agg (not the Velox variants) with a JVM Filter and ObjectHashAggregate. The fallbacks were a real regression, and regenerating the goldens masked it instead of fixing it. I apologize for the confusion this caused.

Root cause (verified against Spark sources). injectOptimizerRule registers into extendedOperatorOptimizationRules, which run inside the Operator Optimization batch of the optimizer. InjectRuntimeFilter runs in a later batch of SparkOptimizer (after super.defaultBatches). So after the rule was moved to the logical level, runtime-filter bloom expressions did not exist yet when it fired. They were never rewritten to the Velox variants, had no Substrait mapping, and both the consuming FilterExec and the producing aggregate fell back to the JVM with R2C/C2R transitions. The earlier !v.isInstanceOf[Literal] guard change could not have fixed this, for the same ordering reason.

Fix (pushed). The rewrite is now split by where the expressions become visible:

  • User-facing might_contain(<scalar subquery>, <non-literal>) pairs: still handled by the logical BloomFilterMightContainJointRewriteRule (your suggestion, @zhztheplayer; it remains the actual GLUTEN-12013 fix, since it bakes the substitution into the originalPlan snapshot that ExpandFallbackPolicy uses for whole-stage AQE fallback).
  • Runtime-filter pairs: handled by a new physical pre-transform rule, RuntimeBloomFilterRewriteRule, restricted to InjectRuntimeFilter's exact shape: both producer and consumer always wrap the key in xxhash64(...). Each side is identifiable on its own, so the rewrite stays consistent even when AQE compiles the subquery separately. This restores the pre-PR native FilterExecTransformer behavior.
  • DataFrame.stat.bloomFilter() builds bloom_filter_agg on the raw column (no xxhash64 wrapper), so it is structurally never matched, and the CallerInfo stack-walk stays removed as @rdtr requested.
  • Literal-valued pairs (SPARK-54336) contain no xxhash64 and stay fully vanilla on both sides.

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).

  • GlutenBloomFilterFallbackSuite: 7/7, including a new regression test asserting a runtime bloom filter keeps a native FilterExecTransformer evaluating velox_might_contain with a VeloxBloomFilterAggregate producer, so this fallback cannot silently return.
  • GlutenBloomFilterAggregateQuerySuite: 14/14, including SPARK-54336.
  • All 7 TPC-DS/TPC-H plan-stability suites against the unchanged main goldens: 322/322.

…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.
@brijrajk
brijrajk force-pushed the fix/12013-bloom-filter-stage-fallback branch from a557928 to 565013e Compare July 10, 2026 19:26
@github-actions

Copy link
Copy Markdown

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] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

@brijrajk brijrajk Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 originalPlan itself (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-InjectRuntimeFilter logical rules, then move the runtime-filter rewrite there.
  • Or a narrowly-guarded joint re-rewrite in the final rule phase (applies to fallen-back plans too), restricted to the xxhash64 shape 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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

@zhztheplayer zhztheplayer Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@brijrajk brijrajk Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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)

  1. The literal-vs-non-literal pair decision (SPARK-54336 vs GLUTEN-12013) needs whole-plan visibility; after PlanSubqueries, a producer-side bloom_filter_agg(col) alone is ambiguous, and there is no xxhash64-style fingerprint to disambiguate.
  2. 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

  1. 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).
  2. Follow-up 1: capacity alignment + a phase-split regression test (prerequisite).
  3. Follow-up 2: close the reversion gap on top, via injectFinal or the policy predicate, your preference.
  4. 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?

@brijrajk brijrajk Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@brijrajk brijrajk Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CORE works for Gluten Core VELOX

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fail to read the native bloom_filter when the stage fallback to java

5 participants