This is an automated email from the ASF dual-hosted git repository.

zhztheplayer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new f938ba60e7 [GLUTEN-12613][VL] Align VeloxBloomFilterAggregate buffer 
capacity with native bloom_filter_agg (#12614)
f938ba60e7 is described below

commit f938ba60e7c316aba160d7d2ad6f048527accb79
Author: BRIJ RAJ KISHORE <[email protected]>
AuthorDate: Mon Jul 27 16:23:40 2026 +0530

    [GLUTEN-12613][VL] Align VeloxBloomFilterAggregate buffer capacity with 
native bloom_filter_agg (#12614)
    
    VeloxBloomFilterAggregate's JVM-side buffer sizing (createAggregationBuffer)
    derived capacity purely from estimatedNumItems, ignoring numBits entirely.
    The native bloom_filter_agg Velox function derives capacity purely from
    numBits (min(numBits, maxNumBits) / 16), ignoring the raw item count once
    numBits is known. For the same input arguments, this produced 
deterministically
    different bit-array sizes (e.g. 16,777,216 bits on JVM vs 8,388,608 bits
    natively for Spark's plain defaults).
    
    Two-phase aggregation runs partial and final stages as separate physical
    operators that can independently land on the JVM or on native Velox. When
    they disagree on capacity, Velox's BloomFilter::merge silently corrupts the
    result instead of throwing, since its size-match guard is a VELOX_DCHECK
    compiled out in release builds -- values inserted relative to one array size
    are later queried relative to a different array size, producing bloom filter
    false negatives.
    
    Fix: size the JVM buffer from numBits using the same formula as the native
    aggregate (velox/functions/sparksql/aggregates/BloomFilterAggAggregate.cpp,
    computeCapacity()), so both engines agree on capacity for identical
    arguments regardless of which engine executes which stage.
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../aggregate/VeloxBloomFilterAggregate.scala      | 29 ++++++++++++------
 .../sql/GlutenBloomFilterAggregateQuerySuite.scala | 35 ++++++++++++++++++++++
 2 files changed, 55 insertions(+), 9 deletions(-)

diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/expression/aggregate/VeloxBloomFilterAggregate.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/expression/aggregate/VeloxBloomFilterAggregate.scala
index 0632e5a81f..88beea49de 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/expression/aggregate/VeloxBloomFilterAggregate.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/expression/aggregate/VeloxBloomFilterAggregate.scala
@@ -55,14 +55,25 @@ case class VeloxBloomFilterAggregate(
 
   override def prettyName: String = "velox_bloom_filter_agg"
 
-  // Mark as lazy so that `estimatedNumItems` is not evaluated during tree 
transformation.
-  private lazy val estimatedNumItems: Long =
-    Math.min(
-      estimatedNumItemsExpression.eval().asInstanceOf[Number].longValue,
-      SQLConf.get
-        .getConfString("spark.sql.optimizer.runtime.bloomFilter.maxNumItems", 
"4000000")
-        .toLong
-    )
+  // Mark as lazy so that `numBits` is not evaluated during tree 
transformation.
+  //
+  // Mirrors the native `bloom_filter_agg` aggregate's own capacity formula
+  // (velox/functions/sparksql/aggregates/BloomFilterAggAggregate.cpp, 
computeCapacity():
+  // `capacity_ = min(numBits_, maxNumBits_) / 16`), which sizes its 
accumulator from `numBits`
+  // alone rather than from the raw item count. If this side derived capacity 
from
+  // `estimatedNumItems` instead (as it previously did), the two engines would 
allocate
+  // different-sized bit arrays for the same input arguments. Since a 
two-phase aggregation's
+  // partial and final stages can independently execute on either engine, that 
mismatch lets
+  // `BloomFilter::merge` (velox/common/base/BloomFilter.h) combine two 
differently-sized
+  // buffers -- its size-match check is a `VELOX_DCHECK`, compiled out in 
release builds, so
+  // the merge silently corrupts the filter instead of failing loudly.
+  private lazy val capacityFromNumBits: Int = {
+    val numBits = numBitsExpression.eval().asInstanceOf[Number].longValue
+    val maxNumBits = SQLConf.get
+      .getConfString("spark.sql.optimizer.runtime.bloomFilter.maxNumBits", 
"67108864")
+      .toLong
+    Math.max(1, Math.toIntExact(Math.min(numBits, maxNumBits) / 16))
+  }
 
   // Mark as lazy so that `updater` is not evaluated during tree 
transformation.
   private lazy val updater: BloomFilterUpdater = child.dataType match {
@@ -99,7 +110,7 @@ case class VeloxBloomFilterAggregate(
     if (!TaskResources.inSparkTask()) {
       throw new UnsupportedOperationException("velox_bloom_filter_agg is not 
evaluable on Driver")
     }
-    VeloxBloomFilter.empty(Math.toIntExact(estimatedNumItems))
+    VeloxBloomFilter.empty(capacityFromNumBits)
   }
 
   override def update(buffer: BloomFilter, input: InternalRow): BloomFilter = {
diff --git 
a/gluten-ut/spark40/src/test/scala/org/apache/spark/sql/GlutenBloomFilterAggregateQuerySuite.scala
 
b/gluten-ut/spark40/src/test/scala/org/apache/spark/sql/GlutenBloomFilterAggregateQuerySuite.scala
index 3eb59d8fec..b43f401f6f 100644
--- 
a/gluten-ut/spark40/src/test/scala/org/apache/spark/sql/GlutenBloomFilterAggregateQuerySuite.scala
+++ 
b/gluten-ut/spark40/src/test/scala/org/apache/spark/sql/GlutenBloomFilterAggregateQuerySuite.scala
@@ -112,6 +112,41 @@ class GlutenBloomFilterAggregateQuerySuite
     }
   }
 
+  // Regression test for the capacity mismatch between 
VeloxBloomFilterAggregate's JVM buffer
+  // sizing and the native bloom_filter_agg aggregate's buffer sizing. Both 
must agree on the
+  // resulting bit-array size for the same (estimatedNumItems, numBits) 
arguments, since a
+  // two-phase aggregation's partial and final stages can independently land 
on either engine
+  // (e.g. via whole-stage fallback), and merging differently-sized buffers 
silently corrupts
+  // the filter (values inserted are later reported as absent) instead of 
throwing.
+  testGluten("Test bloom_filter_agg produces identically-sized bytes on native 
and JVM") {
+    val table = "bloom_filter_test"
+    val numEstimatedItems = 5000000L
+    val sqlString =
+      s"""
+         |SELECT bloom_filter_agg(col,
+         |    cast($numEstimatedItems as long),
+         |    cast($veloxBloomFilterMaxNumBits as long)) AS bf
+         |FROM $table
+      """.stripMargin
+    withTempView(table) {
+      (Seq(Long.MinValue, 0, Long.MaxValue) ++ (1L to 200000L))
+        .toDF("col")
+        .createOrReplaceTempView(table)
+
+      val nativeBytes = 
spark.sql(sqlString).collect()(0).getAs[Array[Byte]]("bf")
+      val jvmBytes = withSQLConf(GlutenConfig.COLUMNAR_HASHAGG_ENABLED.key -> 
"false") {
+        spark.sql(sqlString).collect()(0).getAs[Array[Byte]]("bf")
+      }
+      assert(
+        nativeBytes.length == jvmBytes.length,
+        s"native (${nativeBytes.length} bytes) and JVM (${jvmBytes.length} 
bytes) executions " +
+          "of the same bloom_filter_agg query must produce identically-sized 
buffers, " +
+          "otherwise merging a partial stage from one engine with a final 
stage from the " +
+          "other silently corrupts the filter"
+      )
+    }
+  }
+
   testGluten("Test bloom_filter_agg agg fallback") {
     val table = "bloom_filter_test"
     val numEstimatedItems = 5000000L


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to