andygrove commented on code in PR #5362:
URL: https://github.com/apache/datafusion-comet/pull/5362#discussion_r3799041110


##########
native/core/src/execution/operators/explode.rs:
##########
@@ -0,0 +1,1237 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A temporary fork of DataFusion's `UnnestExec` that respects
+//! `datafusion.execution.batch_size`.
+//!
+//! # Why this fork exists
+//!
+//! DataFusion's `UnnestExec` emits exactly one output batch per input batch, 
however many
+//! rows the unnesting produces, and never consults `batch_size`. For 
`explode` this means
+//! an 8192-row batch of 100-element arrays comes back as a single 819,200-row 
batch, and
+//! peak memory scales with input batch size times array length rather than 
with
+//! `batch_size`.
+//!
+//! The fix has been submitted upstream:
+//!
+//! * <https://github.com/apache/datafusion/issues/24383>
+//! * <https://github.com/apache/datafusion/pull/24384>
+//!
+//! # Deleting this file
+//!
+//! Once Comet moves to a DataFusion release carrying apache/datafusion#24384, 
delete this
+//! module and go back to `datafusion::physical_plan::unnest::UnnestExec` in 
the planner.
+//!
+//! Note that <https://github.com/apache/datafusion-comet/issues/5210> is a 
*different*
+//! unnest cleanup — it tracks adopting upstream `unnest_outer`
+//! (apache/datafusion#22100) to retire `ListEmptyToNullExpr`. The two 
upstream PRs can
+//! land in different releases, so closing 5210 is not a signal to delete this 
fork.
+//!
+//! # What was forked
+//!
+//! The unnesting kernels below (`build_batch` and everything it calls) are 
copied from
+//! `datafusion/physical-plan/src/unnest.rs` at DataFusion 54.1.0, upstream 
revision
+//! `cc7565be1ee97ba8fa2f5d6da373c5e38d81bb13`. They are private to
+//! `datafusion-physical-plan`, so they cannot be called from here without 
copying them.
+//! Leave them semantically unmodified so the eventual deletion is mechanical; 
the
+//! Comet-specific behavior lives entirely in `ExplodeExec` and 
`ExplodeStream`.
+//!
+//! They are not byte-identical to upstream: Comet's rustfmt uses `max_width = 
100` and
+//! edition 2021, DataFusion's uses `max_width = 90` and edition 2024, so 
`cargo fmt`
+//! reflows some signatures. To audit for real changes, reformat this region at
+//! `max_width = 90` and diff it against upstream `unnest.rs`; that reduces 
the difference
+//! to a single cosmetic line wrap in `flatten_struct_cols`. The only 
deliberate edits are
+//! the `lt` import path noted below and dropping upstream's `ListUnnest` 
declaration in
+//! favor of importing the public one.
+//!
+//! Note that 54.1.0 predates upstream's `NullHandling` enum and still uses
+//! `UnnestOptions::preserve_nulls`, which is why the planner wraps empty 
arrays with
+//! `ListEmptyToNullExpr` to get Spark's `explode_outer` semantics.
+
+use arrow::array::{
+    new_null_array, Array, ArrayRef, AsArray, FixedSizeListArray, Int64Array, 
LargeListArray,
+    LargeListViewArray, ListArray, ListViewArray, PrimitiveArray, Scalar, 
StructArray,
+};
+use arrow::compute::kernels::length::length;
+use arrow::compute::kernels::zip::zip;
+use arrow::compute::{cast, is_not_null, kernels, sum};
+use arrow::datatypes::{DataType, Int64Type, SchemaRef};
+use arrow::record_batch::RecordBatch;
+// Upstream imports this as `arrow_ord::cmp::lt`; Comet reaches it through 
`arrow`,
+// which does not have `arrow_ord` as a direct dependency.
+use arrow::compute::kernels::cmp::lt;
+use datafusion::common::{
+    exec_datafusion_err, exec_err, internal_err, HashMap, HashSet, Result, 
UnnestOptions,
+};
+use datafusion::execution::TaskContext;
+use datafusion::physical_expr::EquivalenceProperties;
+use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion::physical_plan::metrics::{
+    BaselineMetrics, Count, ExecutionPlanMetricsSet, MetricBuilder, 
MetricsSet, RecordOutput,
+};
+// `ListUnnest` is the one item the copied region below does NOT need to 
duplicate: unlike the
+// kernels, upstream exports it publicly.
+use datafusion::physical_plan::unnest::ListUnnest;
+use datafusion::physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties, 
RecordBatchStream,
+    SendableRecordBatchStream,
+};
+use futures::{Stream, StreamExt};
+use std::cmp::{self, Ordering};
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{ready, Context, Poll};
+
+/// Comet's explode operator: DataFusion's `UnnestExec` with the input 
consumed in chunks so
+/// that output batches respect `datafusion.execution.batch_size`.
+#[derive(Debug)]
+pub struct ExplodeExec {
+    child: Arc<dyn ExecutionPlan>,
+    schema: SchemaRef,
+    list_column_indices: Vec<ListUnnest>,
+    struct_column_indices: Vec<usize>,
+    options: UnnestOptions,
+    metrics: ExecutionPlanMetricsSet,
+    cache: Arc<PlanProperties>,
+}
+
+impl ExplodeExec {
+    pub fn new(
+        child: Arc<dyn ExecutionPlan>,
+        list_column_indices: Vec<ListUnnest>,
+        struct_column_indices: Vec<usize>,
+        schema: SchemaRef,
+        options: UnnestOptions,
+    ) -> Self {
+        // Unnesting invalidates the child's orderings and constraints for the 
unnested
+        // columns, and Comet plans explode on a single partition, so start 
from empty
+        // equivalences rather than trying to project the child's.
+        let cache = Arc::new(PlanProperties::new(
+            EquivalenceProperties::new(Arc::clone(&schema)),
+            Partitioning::UnknownPartitioning(1),
+            EmissionType::Incremental,
+            Boundedness::Bounded,
+        ));
+
+        Self {
+            child,
+            schema,
+            list_column_indices,
+            struct_column_indices,
+            options,
+            metrics: ExecutionPlanMetricsSet::new(),
+            cache,
+        }
+    }
+}
+
+impl DisplayAs for ExplodeExec {
+    fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> 
std::fmt::Result {
+        match t {
+            DisplayFormatType::Default | DisplayFormatType::Verbose => {
+                write!(f, "CometExplodeExec")
+            }
+            DisplayFormatType::TreeRender => unimplemented!(),
+        }
+    }
+}
+
+impl ExecutionPlan for ExplodeExec {
+    fn name(&self) -> &str {
+        "CometExplodeExec"
+    }
+
+    fn schema(&self) -> SchemaRef {
+        Arc::clone(&self.schema)
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.child]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        if children.len() != 1 {
+            return internal_err!("ExplodeExec expects exactly one child");
+        }
+        Ok(Arc::new(ExplodeExec::new(
+            Arc::clone(&children[0]),
+            self.list_column_indices.clone(),
+            self.struct_column_indices.clone(),
+            Arc::clone(&self.schema),
+            self.options.clone(),
+        )))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        let batch_size = context.session_config().batch_size();
+        let input = self.child.execute(partition, context)?;
+
+        Ok(Box::pin(ExplodeStream {
+            input,
+            schema: Arc::clone(&self.schema),
+            list_type_columns: self.list_column_indices.clone(),
+            struct_column_indices: 
self.struct_column_indices.iter().copied().collect(),
+            options: self.options.clone(),
+            baseline_metrics: BaselineMetrics::new(&self.metrics, partition),
+            input_batches: 
MetricBuilder::new(&self.metrics).counter("input_batches", partition),
+            input_rows: 
MetricBuilder::new(&self.metrics).counter("input_rows", partition),
+            batch_size,
+            pending_input: None,
+            pending_output: None,
+        }))
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        &self.cache
+    }
+
+    fn metrics(&self) -> Option<MetricsSet> {
+        Some(self.metrics.clone_inner())
+    }
+}
+
+/// An input batch being unnested incrementally, a chunk of rows at a time.
+struct PendingInput {
+    /// The full input batch. Rows before `row_offset` have already been 
unnested.
+    batch: RecordBatch,
+    /// Index of the next input row to unnest.
+    row_offset: usize,
+    /// How many output rows each input row expands into, indexed by input row.
+    ///
+    /// Empty when the expansion factor cannot be predicted from the input 
alone, in which
+    /// case the whole remaining input is unnested in one call and only the 
output is split.
+    /// See [`ExplodeStream::predict_output_lens`].
+    output_lens: Vec<usize>,
+}
+
+impl PendingInput {
+    fn remaining_rows(&self) -> usize {
+        self.batch.num_rows() - self.row_offset
+    }
+
+    /// How many input rows to unnest next so the resulting batch holds at 
most `batch_size`
+    /// rows.
+    ///
+    /// Always returns at least 1 while rows remain, so the stream always 
makes progress: a
+    /// single input row is never split across output batches, so one row 
whose array is
+    /// longer than `batch_size` still produces one oversized build, which is 
then sliced
+    /// down on the way out.
+    fn next_chunk_rows(&self, batch_size: usize) -> usize {
+        let remaining = self.remaining_rows();
+        if self.output_lens.is_empty() {
+            return remaining;
+        }
+
+        let mut rows = 0;
+        let mut output_rows = 0usize;
+        while rows < remaining {
+            let len = self.output_lens[self.row_offset + rows];
+            if rows > 0 && output_rows.saturating_add(len) > batch_size {
+                break;
+            }
+            output_rows += len;
+            rows += 1;
+        }
+        rows
+    }
+}
+
+/// A stream that unnests its input, bounding output batches to `batch_size` 
rows.
+struct ExplodeStream {
+    input: SendableRecordBatchStream,
+    schema: SchemaRef,
+    list_type_columns: Vec<ListUnnest>,
+    struct_column_indices: HashSet<usize>,
+    options: UnnestOptions,
+    baseline_metrics: BaselineMetrics,
+    input_batches: Count,
+    input_rows: Count,
+    /// Target number of rows per output batch, from 
`datafusion.execution.batch_size`.
+    batch_size: usize,
+    /// Rows of the current input batch that have not been unnested yet.
+    pending_input: Option<PendingInput>,
+    /// Unnested rows that have been built but not emitted yet.
+    pending_output: Option<RecordBatch>,
+}
+
+impl RecordBatchStream for ExplodeStream {
+    fn schema(&self) -> SchemaRef {
+        Arc::clone(&self.schema)
+    }
+}
+
+impl Stream for ExplodeStream {
+    type Item = Result<RecordBatch>;
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> 
Poll<Option<Self::Item>> {
+        self.poll_next_impl(cx)
+    }
+}
+
+impl ExplodeStream {
+    fn poll_next_impl(&mut self, cx: &mut Context<'_>) -> 
Poll<Option<Result<RecordBatch>>> {
+        loop {
+            // Emit already-unnested rows first, at most `batch_size` at a 
time.
+            if let Some(batch) = self.pending_output.take() {
+                let (emit, rest) = split_off_head(batch, self.batch_size);
+                self.pending_output = rest;
+                (&emit).record_output(&self.baseline_metrics);
+                return Poll::Ready(Some(Ok(emit)));
+            }
+
+            // Unnest the next chunk of the input batch already in hand.
+            if let Some(pending) = self.pending_input.as_mut() {
+                if pending.remaining_rows() == 0 {
+                    self.pending_input = None;
+                    continue;
+                }
+
+                let timer = self.baseline_metrics.elapsed_compute().timer();
+
+                let rows = pending.next_chunk_rows(self.batch_size);
+                let chunk = pending.batch.slice(pending.row_offset, rows);
+                pending.row_offset += rows;
+
+                let result = build_batch(
+                    &chunk,
+                    &self.schema,
+                    &self.list_type_columns,
+                    &self.struct_column_indices,
+                    &self.options,
+                );
+                timer.done();
+
+                // A chunk can legitimately produce no rows at all (for 
example rows whose
+                // arrays are all empty and `preserve_nulls` is false); move 
on to the next
+                // chunk rather than emitting an empty batch.
+                self.pending_output = result?.filter(|batch| batch.num_rows() 
> 0);
+                continue;
+            }
+
+            // Otherwise pull the next input batch.
+            return Poll::Ready(match ready!(self.input.poll_next_unpin(cx)) {
+                Some(Ok(batch)) => {
+                    self.input_batches.add(1);
+                    self.input_rows.add(batch.num_rows());
+                    if batch.num_rows() == 0 {
+                        continue;
+                    }
+
+                    let timer = 
self.baseline_metrics.elapsed_compute().timer();
+                    let output_lens = self.predict_output_lens(&batch);
+                    timer.done();
+
+                    match output_lens {
+                        Ok(output_lens) => {
+                            self.pending_input = Some(PendingInput {
+                                batch,
+                                row_offset: 0,
+                                output_lens,
+                            });
+                            continue;
+                        }
+                        Err(e) => Some(Err(e)),
+                    }
+                }
+                other => other,
+            });
+        }
+    }
+
+    /// Compute how many output rows each input row of `batch` will expand 
into, so the input
+    /// can be chunked to keep each build bounded.
+    ///
+    /// Returns an empty vec when the count cannot be derived from the input 
alone, which is
+    /// the signal to unnest the whole batch in one call:
+    ///
+    /// * With no list columns, unnesting only widens structs and leaves the 
row count alone,
+    ///   so the output is already bounded by the input batch size.
+    /// * With recursion (`depth > 1`), a row's expansion depends on the 
lengths of inner
+    ///   lists that only exist after the outer levels have been unnested, so 
it cannot be
+    ///   predicted up front. Comet only plans depth-1 explode today, but the 
fallback keeps
+    ///   this correct if that changes.
+    fn predict_output_lens(&self, batch: &RecordBatch) -> Result<Vec<usize>> {
+        if self.list_type_columns.is_empty()
+            || self
+                .list_type_columns
+                .iter()
+                .any(|unnest| unnest.depth != 1)
+        {
+            return Ok(vec![]);
+        }
+
+        let list_arrays: Vec<ArrayRef> = self
+            .list_type_columns
+            .iter()
+            .map(|unnest| 
Arc::clone(batch.column(unnest.index_in_input_schema)))
+            .collect();
+
+        // The same per-row length that `list_unnest_at_level` derives when it 
actually
+        // unnests, so the chunk boundaries it produces are exact.
+        let longest_length = find_longest_length(&list_arrays, &self.options)?;

Review Comment:
   Done, and it now matches what the upstream PR does. `predict_output_lens` 
keeps the result as a `PrimitiveArray<Int64Type>` rather than collecting into 
`Vec<usize>`, `PendingInput::chunk_lengths` hands out a zero-copy slice of it 
per chunk, and that slice threads down through `build_batch` into 
`list_unnest_at_level`, which uses it in place of calling `find_longest_length` 
again. Reuse is gated on `max_recursion == 1`, since with recursion the deeper 
levels depend on arrays that do not exist yet — the same reason the prediction 
is skipped there in the first place.
   
   This does mean two functions in the vendored region gain a parameter, which 
cuts against the "do not change it semantically" banner, so I called it out 
explicitly in the module docs as a deliberate edit that is itself part of 
apache/datafusion#24384 and therefore disappears with the rest of the fork.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to