sunchao commented on code in PR #7316: URL: https://github.com/apache/datafusion-comet/pull/7316#discussion_r4447047506
########## native/core/src/execution/planner/scalar_expansion.rs: ########## @@ -0,0 +1,289 @@ +// 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. + +//! Bounds the rows per batch under an operator that expands a large string scalar. +//! +//! An aggregate, a hash partitioner and most string kernels expand a scalar argument to the +//! batch's length: a 256 KiB string over the default 8192-row batch needs 2 GiB of value bytes, +//! past the 32-bit offsets of Arrow's `Utf8` and `Binary` arrays. Spark evaluates such scalars +//! row by row and never materializes the column. See +//! <https://github.com/apache/datafusion-comet/issues/7247>. +//! +//! The bound is enforced right under the operator that evaluates the expressions, with a +//! [`BatchSplitExec`], because the operators below do not keep the batch size they were +//! handed: a filter coalesces its output back up to the session's batch size, a window emits +//! its whole partition and a broadcast arrives as a few wide batches. No width can be assumed, +//! so the split is installed whenever the operator expands a string or binary scalar at all. + +use std::sync::Arc; + +use arrow::datatypes::{DataType, Schema}; +use arrow::record_batch::{RecordBatch, RecordBatchOptions}; +use datafusion::common::ScalarValue; +use datafusion::logical_expr::{ColumnarValue, Volatility}; +use datafusion::physical_expr::expressions::{Column, InListExpr}; +use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr}; +use datafusion::physical_plan::ExecutionPlan; +use datafusion_comet_operators::BatchSplitExec; +use datafusion_comet_spark_expr::jvm_udf::JvmScalarUdfExpr; +use datafusion_comet_spark_expr::monotonically_increasing_id::MonotonicallyIncreasingId; +use datafusion_comet_spark_expr::{ + RandExpr, RandStrExpr, RandnExpr, ShuffleExpr, Subquery, UuidExpr, +}; + +use super::{PhysicalPlanner, TEST_EXEC_CONTEXT_ID}; + +/// The most value bytes the string and binary scalars of one operator may take once expanded to +/// a batch's length. Arrow's 32-bit offsets make 2 GiB the hard limit; half of it leaves room for +/// kernels that append to the expanded value, such as `concat`, and matches what an Arrow Java +/// vector can hold before its reallocation fails. +const SCALAR_EXPANSION_BYTES: usize = 1 << 30; + +/// The most rows per batch at which `scalar_bytes`, expanded to the batch's length, stay within +/// `SCALAR_EXPANSION_BYTES`; unbounded when there is nothing to expand. +fn scalar_rows_cap(scalar_bytes: usize) -> usize { + if scalar_bytes == 0 { + return usize::MAX; + } + (SCALAR_EXPANSION_BYTES / scalar_bytes).max(1) +} + +impl PhysicalPlanner { + /// Wraps `input` so that the batches it emits keep the string and binary scalars in `exprs`, + /// expanded to a batch's length, within `SCALAR_EXPANSION_BYTES` and the session's batch + /// size. Returns `input` as is when `exprs` expand no scalar. + pub(crate) fn bound_scalar_expansion<'a>( + &self, + input: Arc<dyn ExecutionPlan>, + exprs: impl IntoIterator<Item = &'a Arc<dyn PhysicalExpr>>, + ) -> Arc<dyn ExecutionPlan> { + let schema = input.schema(); + let bytes: usize = exprs + .into_iter() + .map(|expr| self.expanded_scalar_bytes(expr, &schema)) + .sum(); + if bytes == 0 { + return input; + } + let batch_size = self.session_ctx.copied_config().batch_size(); + Arc::new(BatchSplitExec::new( + input, + scalar_rows_cap(bytes).min(batch_size), + )) + } + + /// The bytes of the string and binary scalars that evaluating `expr` expands to the batch's + /// length, added up, so that a scalar used twice counts twice. + /// + /// A scalar is any subtree without a column reference or a volatile function, evaluated + /// here on a one-row batch: `repeat(<subquery>, 8)` is wider than the subquery result it is + /// built from. A subtree that fails to evaluate counts nothing, so that its error surfaces + /// where Spark's would, at execution. The members of an `IN` list are compared and never + /// expanded. + fn expanded_scalar_bytes(&self, expr: &Arc<dyn PhysicalExpr>, schema: &Schema) -> usize { + if let Some(in_list) = expr.downcast_ref::<InListExpr>() { + return self.expanded_scalar_bytes(in_list.expr(), schema); + } + if !is_column_free(expr) { + return expr + .children() + .into_iter() + .map(|child| self.expanded_scalar_bytes(child, schema)) + .sum(); + } + if !matches!( + expr.data_type(schema), + Ok(DataType::Utf8 | DataType::Binary) + ) { + return 0; + } + // A volatile function yields a value per row and advances a seeded generator when + // evaluated. A JVM UDF is the user's code, which may have side effects or be slow, and a + // planner unit test has no JVM to read a subquery result from. + if contains(expr, &|e| is_volatile(e) || e.is::<JvmScalarUdfExpr>()) Review Comment: Could this branch recurse into safe child subtrees instead of returning zero for the whole expression? A volatile sibling does not stop a deterministic literal from being expanded for every row. On Spark 4.1.3 with this head's native library: ```sql SELECT sum(length(c)) FROM ( SELECT concat(repeat('x', 262144), uuid()) AS c FROM range(0, 8192, 1, 1) ) ``` Spark returns `2147778560`; Comet's default 8192-row batch fails with `byte array offset overflow`. Changing only `spark.comet.batchSize` to 256 gives the same result as Spark with the native range/project/aggregate plan. The concat has no column references, so the volatile-descendant check skips its 256 KiB deterministic argument and no split is installed. Avoid evaluating `uuid()` while still accounting for the deterministic child, and add this mixed volatile/scalar shape to the regression coverage. ########## native/core/src/execution/planner/shuffle.rs: ########## @@ -85,6 +85,12 @@ impl OperatorBuilder for ShuffleWriterBuilder { let partitioning = planner .create_partitioning(writer.partitioning.as_ref().unwrap(), writer_input.schema())?; + let writer_input = match &partitioning { + CometPartitioning::Hash(exprs, _) => { + planner.bound_scalar_expansion(writer_input, exprs) Review Comment: Could this also bound the expressions in `CometPartitioning::RangePartitioning`? Its writer evaluates every ordering expression and calls `into_array(input.num_rows())` in `multi_partition.rs`, but the match here only protects Hash. On Spark 4.1.3 with native shuffle enabled: ```scala spark.range(0, 8192, 1, 1) .repartitionByRange(2, expr( "concat(repeat('x',262144),cast(id AS string))").asc) .collect() ``` The executed plan retains that expression in `CometExchange` directly above `CometRange`; there is no protected projection in between. With the default 8192-row batch, the native writer fails with `Error inserting batch: Execution error: byte array offset overflow`. Spark succeeds, and changing only `spark.comet.batchSize` to 256 also succeeds natively; both controls return 8192 ids with sum `33550336`. Applying the same bound to the range ordering expressions and covering this shuffle shape would close this path. -- 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]
