You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/10/20 11:57:29 UTC

(datafusion) 01/01: fix: compact sparse string views in FilterExec coalescing (#26436)

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

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-26436-4e84f71c49eb141e8353cc04f1d3ae390fe38543
in repository https://gitbox.apache.org/repos/asf/datafusion.git

commit d6fee726feadee177100ecc0e1824bb4e4ccc0de
Author: Jay Zhan <ja...@gmail.com>
AuthorDate: Tue Oct 20 11:57:10 2026 +0000

    fix: compact sparse string views in FilterExec coalescing (#26436)
    
    ## Which issue does this PR close?
    
    - Fixes a regression from #25503 (not released yet, so it would ship in
    56.0.0).
    
    ## Rationale for this change
    
    Since #25503, a selective filter over `Utf8View`/`BinaryView` columns
    keeps the whole input string buffers alive. ClickBench Q21-Q23 use about
    2-3x more memory and Q22/Q23 run 1.6-1.7x slower with the default
    `schema_force_view_types = true`.
    
    #25503 made `FilterExec` call `BatchCoalescer::push_batch_with_filter`
    instead of `filter_record_batch` + `push_batch`. When at most 1/16 of
    the rows are selected, arrow-select 60 takes a sparse path that filters
    the views but reuses the source data buffers as they are
    (`InProgressByteViewArray::copy_rows_by_filter_from` calls
    `append_views_and_update_buffer_index(..., false)`). The old path went
    through `push_batch`, which sees that the filtered array uses less than
    half of its buffers (`need_gc`) and copies the selected strings into
    compact buffers. So each output batch now pins every data page it was
    filtered from, and operators that hold batches (aggregation, sort, TopK)
    hold all of them. arrow-rs main has the same code.
    
    Repro (ClickBench `hits_partitioned`, default settings):
    
    ```sql
    SET datafusion.execution.parquet.binary_as_string = true;
    CREATE EXTERNAL TABLE hits STORED AS PARQUET LOCATION 'benchmarks/data/hits_partitioned';
    SELECT * FROM hits WHERE "URL" LIKE '%google%' ORDER BY "EventTime" LIMIT 10; -- Q23
    ```
    
    ## What changes are included in this PR?
    
    `LimitedBatchCoalescer::push_batch_with_filter` sends batches that have
    a `Utf8View` or `BinaryView` column with data buffers through
    `filter_record_batch` + `push_batch`, where arrow compacts sparse string
    views. All other batches keep the fused path from #25503.
    
    The root cause is tracked upstream in apache/arrow-rs#11635; once
    arrow-select compacts views on its sparse path, this check can be
    removed.
    
    This changes nothing for view batches when the filter is not sparse:
    arrow already materializes the filter and calls `push_batch` for them.
    So the only path that changes is the sparse one for view columns, which
    goes back to its behavior before #25503.
    
    ## What is the testing strategy for this PR?
    
    - New unit test `test_push_batch_with_filter_compacts_string_views`: a
    1/100 filter over 1600 long strings. Without the change the output keeps
    all 114,688 input string bytes; with it, less than a quarter.
    - Benchmarks, release-nonlto, same commit (5f2158269e) with and without
    this PR, plus #25503 reverted for reference; median of 3 interleaved
    runs, peak RSS:
    
    | Query | main | this PR | #25503 reverted |
    |---|---|---|---|
    | ClickBench Q21 | 0.60 s, 2.1 GB | 0.59 s, 1.1 GB | 0.62 s, 1.2 GB |
    | ClickBench Q22 | 2.14 s, 4.1 GB | 1.40 s, 1.5 GB | 1.26 s, 1.4 GB |
    | ClickBench Q23 | 7.10 s, 6.9 GB | 4.35 s, 3.4 GB | 4.33 s, 3.4 GB |
    | ClickBench total (43 queries) | 29.8 s | 25.5 s | 25.9 s |
    | TPC-H SF10 total (22 queries) | 6.89 s | 6.88 s | 6.93 s |
    
    Other ClickBench queries differ only within run-to-run noise. TPC-H
    keeps the #25503 result.
    
    ## Are there any user-facing changes?
    
    No.
---
 datafusion/physical-plan/src/coalesce/mod.rs | 63 +++++++++++++++++++++++++---
 1 file changed, 58 insertions(+), 5 deletions(-)

diff --git a/datafusion/physical-plan/src/coalesce/mod.rs b/datafusion/physical-plan/src/coalesce/mod.rs
index 70c86368c1..107696cd58 100644
--- a/datafusion/physical-plan/src/coalesce/mod.rs
+++ b/datafusion/physical-plan/src/coalesce/mod.rs
@@ -15,9 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use arrow::array::{Array, BooleanArray, RecordBatch};
-use arrow::compute::{BatchCoalescer, prep_null_mask_filter};
-use arrow::datatypes::SchemaRef;
+use arrow::array::{Array, AsArray, BooleanArray, RecordBatch};
+use arrow::compute::{BatchCoalescer, filter_record_batch, prep_null_mask_filter};
+use arrow::datatypes::{DataType, SchemaRef};
 use datafusion_common::{Result, assert_or_internal_err};
 
 /// Concatenate multiple [`RecordBatch`]es and apply a limit
@@ -124,7 +124,11 @@ impl LimitedBatchCoalescer {
     /// avoiding a separate materialization pass compared to calling
     /// [`filter_record_batch`] followed by [`Self::push_batch`].
     ///
-    /// [`filter_record_batch`]: arrow::compute::filter_record_batch
+    /// Batches with `Utf8View` or `BinaryView` columns that reference data
+    /// buffers are filtered first and then pushed with [`Self::push_batch`]:
+    /// arrow's fused path keeps the whole input data buffers alive for the few
+    /// selected strings, while [`Self::push_batch`] copies them into compact
+    /// buffers.
     pub fn push_batch_with_filter(
         &mut self,
         batch: RecordBatch,
@@ -135,6 +139,11 @@ impl LimitedBatchCoalescer {
             "LimitedBatchCoalescer: cannot push batch after finish"
         );
 
+        if has_view_data_buffers(&batch) {
+            let batch = filter_record_batch(&batch, filter)?;
+            return self.push_batch(batch);
+        }
+
         let Some(fetch) = self.fetch else {
             self.inner.push_batch_with_filter(batch, filter)?;
             return Ok(PushBatchStatus::Continue);
@@ -191,13 +200,23 @@ impl LimitedBatchCoalescer {
     }
 }
 
+/// Returns true if `batch` has a `Utf8View` or `BinaryView` column whose views
+/// reference data buffers (strings longer than 12 bytes).
+fn has_view_data_buffers(batch: &RecordBatch) -> bool {
+    batch.columns().iter().any(|array| match array.data_type() {
+        DataType::Utf8View => !array.as_string_view().data_buffers().is_empty(),
+        DataType::BinaryView => !array.as_binary_view().data_buffers().is_empty(),
+        _ => false,
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
     use std::ops::Range;
     use std::sync::Arc;
 
-    use arrow::array::UInt32Array;
+    use arrow::array::{StringViewArray, UInt32Array};
     use arrow::compute::concat_batches;
     use arrow::datatypes::{DataType, Field, Schema};
 
@@ -360,6 +379,40 @@ mod tests {
         assert!(coalescer.next_completed_batch().is_none());
     }
 
+    /// A selective filter over long strings must not keep the whole input
+    /// data buffer alive for the few selected rows.
+    #[test]
+    fn test_push_batch_with_filter_compacts_string_views() {
+        let strings =
+            (0..1600).map(|i| format!("a string longer than twelve bytes {i:05}"));
+        let array = StringViewArray::from_iter_values(strings);
+        let input_bytes: usize = array.data_buffers().iter().map(|b| b.capacity()).sum();
+        let schema = Arc::new(Schema::new(vec![Field::new(
+            "s",
+            DataType::Utf8View,
+            false,
+        )]));
+        let batch =
+            RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(array)]).unwrap();
+        // Select every 100th row: 16 of 1600 rows
+        let filter = BooleanArray::from_iter((0..1600).map(|i| Some(i % 100 == 0)));
+
+        let mut coalescer = LimitedBatchCoalescer::new(schema, 100, None);
+        coalescer.push_batch_with_filter(batch, &filter).unwrap();
+        coalescer.finish().unwrap();
+        let output = coalescer.next_completed_batch().unwrap();
+
+        let output = output.column(0).as_string_view();
+        assert_eq!(output.len(), 16);
+        assert_eq!(output.value(1), "a string longer than twelve bytes 00100");
+        let output_bytes: usize =
+            output.data_buffers().iter().map(|b| b.capacity()).sum();
+        assert!(
+            output_bytes * 4 < input_bytes,
+            "output keeps {output_bytes} of {input_bytes} input string bytes"
+        );
+    }
+
     /// Test for [`LimitedBatchCoalescer`]
     ///
     /// Pushes the input batches to the coalescer and verifies that the resulting


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org