sunchao commented on code in PR #5889:
URL: https://github.com/apache/datafusion-comet/pull/5889#discussion_r4367085460
##########
native/core/src/execution/planner/macros.rs:
##########
@@ -93,14 +93,32 @@ macro_rules! binary_expr_builder {
&$operator,
&input_schema,
);
- datafusion_comet_spark_expr::spark_comparison(
+ let comparison = datafusion_comet_spark_expr::spark_comparison(
left,
$operator,
right,
input_schema.as_ref(),
planner.float_operands(),
- )
- .map_err(Into::into)
+ )?;
+ if expr.null_short_circuit
+ && matches!(
+ $operator,
+ datafusion::logical_expr::Operator::Eq
+ | datafusion::logical_expr::Operator::NotEq
+ | datafusion::logical_expr::Operator::Lt
+ | datafusion::logical_expr::Operator::LtEq
+ | datafusion::logical_expr::Operator::Gt
+ | datafusion::logical_expr::Operator::GtEq
+ )
+ {
+ datafusion_comet_spark_expr::NullShortCircuit::wrap(
+ comparison,
+ input_schema.as_ref(),
+ )
+ .map_err(Into::into)
+ } else {
+ Ok(comparison)
Review Comment:
[P2] Preserve per-row short-circuiting for `AND` and `OR` on projections
enabled by merged subqueries. This branch returns an ordinary DataFusion
`BinaryExpr`. Its Boolean preselection is heuristic, so a mixed batch can
evaluate the right operand where Spark already knows the answer. Apply the
appropriate row mask when evaluation is observable, or keep the projection on
Spark.
Evidence: With ANSI and subquery reuse on, AQE off,
`items(id,txt)=[(1,'bad'),(2,'2')]` in one batch, and `nums(k)=[0,1]`, run
`SELECT id, id=(SELECT max(k) FROM nums) OR CAST(txt AS INT)=2, (SELECT min(k)
FROM nums) FROM items`. Spark and base returned `(1,true,0),(2,true,0)`. Head
raised `CAST_INVALID_INPUT`. Replacing the predicate with `id<>(SELECT max(k)
FROM nums) AND CAST(txt AS INT)=2` returned `(1,false,0),(2,true,0)` on both
controls and failed on head. The 50% split falls outside DataFusion 55.2.0’s
20% preselection threshold.
##########
native/spark-expr/src/datetime_funcs/extract_date_part.rs:
##########
@@ -131,51 +181,69 @@ macro_rules! extract_date_part {
&self,
args: ScalarFunctionArgs,
) -> datafusion::common::Result<ColumnarValue> {
- let args: [ColumnarValue; 1] =
args.args.try_into().map_err(|_| {
+ let [arg]: [ColumnarValue; 1] =
args.args.try_into().map_err(|_| {
internal_datafusion_err!(concat!($fn_name, " expects
exactly one argument"))
})?;
-
- match args {
- [ColumnarValue::Array(array)] => {
- // Fast path: when no offset applies the field is
arithmetic on the stored
- // microseconds, so no calendar datetime is built per
row. `unary` carries
- // the null buffer over untouched.
- if let Some(micros) = micros_without_offset(&array,
&self.timezone) {
- // `unary` evaluates every slot and vectorizes;
`unary_opt` visits
- // only valid indices but costs more per element.
The `date_part`
- // path this replaces uses `unary_opt`, so an
almost entirely null
- // batch was nearly free there. Skipping only pays
off once most of
- // the batch is null, so switch on density rather
than on the mere
- // presence of a null.
- let result: Int32Array = if micros.null_count() *
2 <= micros.len() {
- micros.unary::<_, Int32Type>($kernel)
- } else {
- micros.unary_opt::<_, Int32Type>(|v|
Some($kernel(v)))
- };
- return Ok(ColumnarValue::Array(Arc::new(result)));
- }
-
- // TimestampNTZ values are stored as local wall-clock
time, so the date
- // part is extracted directly. Timezone-aware
timestamps are stored in UTC
- // and must be shifted to the session timezone first.
- let array = if is_timestamp_ntz(array.data_type()) {
- array
+ if let ColumnarValue::Scalar(ScalarValue::TimestampMicrosecond(
+ Some(micros),
+ Some(_),
+ )) = &arg
+ {
+ if !is_utc_timezone(&self.timezone)
+ && DateTime::from_timestamp_micros(*micros).is_none()
+ {
+ // Spark's microsecond timestamps extend beyond
chrono's calendar range.
+ // The first/last timezone offset spans extend
indefinitely, so obtain the
+ // offset at the nearest endpoint without clamping the
clock value. This
+ // retains chrono-tz's documented far-future DST
limitation.
+ let timezone: Tz = self.timezone.parse()?;
+ let endpoint = if *micros < 0 {
+ NaiveDateTime::MIN
} else {
- array_with_timezone(
- array,
- self.timezone.clone(),
- Some(&DataType::Timestamp(
- Microsecond,
- Some(self.timezone.clone().into()),
- )),
- )?
+ NaiveDateTime::MAX
};
- let result = date_part(&array,
DatePart::$date_part_variant)?;
- Ok(ColumnarValue::Array(result))
+ let offset = timezone
+ .offset_from_utc_datetime(&endpoint)
Review Comment:
[P2] Use the regional offset for the requested instant. Sampling chrono’s
endpoint applies standard time to a far-future summer instant in Los Angeles.
An ordinary `IF` can return a runtime scalar, bypassing the Scala guard for
subquery consumers. Use Spark-equivalent recurring timezone rules or defer this
scalar clock shape to Spark.
Evidence: Set `spark.sql.session.timeZone=America/Los_Angeles` and put
integer IDs 1 and 2 in one Parquet batch. Run `SELECT
hour(IF(id>0,timestamp_micros(9089364630896000000L),timestamp_micros(CAST(id AS
BIGINT)))) FROM items`. The instant is July 1 of year 290000 at 12:34:56 UTC.
Spark 4.1.3 returned `[5,5]`. Head returned `[4,4]`. The base rejected the
scalar input, so the new path turns an explicit failure into an incorrect hour.
##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,362 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.jdk.CollectionConverters._
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
BoundReference, CaseWhen, Cast, Coalesce, CreateArray, CreateMap,
CreateNamedStruct, DateFromUnixDate, DayOfWeek, ElementAt, EqualNullSafe,
EqualTo, Expression, ExprId, GetArrayItem, GetArrayStructFields, GetMapValue,
GetStructField, GreaterThan, GreaterThanOrEqual, Greatest, Hour, If, In, InSet,
IsNotNull, IsNull, KnownNotNull, KnownNullable, Least, LessThan,
LessThanOrEqual, Literal, Minute, NamedExpression, PreciseTimestampConversion,
Second, SortOrder, ToUnixTimestamp, TryEval, UnixDate, UnixTimestamp, WeekDay}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.comet.CometExec
+import org.apache.spark.sql.execution.{ExecSubqueryExpression, ExpandExec,
GenerateExec, ScalarSubquery, SparkPlan, UnionExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
QueryStageExec}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types._
+
+import com.google.protobuf.Message
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
import org.apache.comet.serde.QueryPlanSerde.{serializeDataType,
supportedDataType}
object CometScalarSubquery extends CometExpressionSerde[ScalarSubquery] {
+ private val structSubqueryInputs =
+ new TreeNodeTag[Set[ExprId]]("CometStructScalarSubqueryInputs")
+ private val structSubqueryContext =
+ new TreeNodeTag[Boolean]("CometStructScalarSubqueryContext")
+ private val currentStructSubqueryInputs = new
DynamicVariable[Set[ExprId]](Set.empty)
+ private val currentPlanHasStructSubquery = new
DynamicVariable[Boolean](false)
+
+ /**
+ * Trace struct scalar values before native conversion replaces their
producing projections.
+ * Attribute object identity is insufficient: aliases, nullable join outputs
and reused stages
+ * can create fresh attributes. Keep provenance by ExprId on the owning plan
instead.
+ *
+ * This is recomputed on every rule application, including the plans hidden
by AQE/reuse nodes.
+ * No provenance is inferred from a column's data type or carried in a
global ExprId registry.
+ */
+ private[comet] def tagPlanInputs(root: SparkPlan): Unit = {
+ val visited = new java.util.IdentityHashMap[SparkPlan, Set[ExprId]]()
+ def remember(plan: SparkPlan, sources: Set[ExprId]): Unit = {
+ val _ = visited.put(plan, sources)
+ }
+
+ def visit(plan: SparkPlan): Set[ExprId] = {
+ if (visited.containsKey(plan)) return visited.get(plan)
+ remember(plan, Set.empty)
+ plan.unsetTagValue(structSubqueryInputs)
+ plan.unsetTagValue(structSubqueryContext)
+
+ val original = plan match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val children = plan match {
+ case reused: ReusedExchangeExec => Seq(reused.child)
+ case stage: QueryStageExec => Seq(stage.plan)
+ case adaptive: AdaptiveSparkPlanExec => Seq(adaptive.executedPlan)
+ case comet: CometExec if comet.children.isEmpty =>
Seq(comet.originalPlan)
+ case other => other.children
+ }
+ val childSources = children.map(visit)
+ var sources = childSources.flatten.toSet
+ // A struct subquery can enable native conversion of both its operator
and ancestors.
+ // Keep that context even when a later projection drops the struct:
sibling/ancestor
+ // calendar consumers can still receive ordinary columns outside the
native range.
+ val hasStructContext =
+ children.exists(_.getTagValue(structSubqueryContext).contains(true)) ||
+ original.expressions.exists(hasStructSubquerySource(_, sources))
+ plan.setTagValue(structSubqueryContext, hasStructContext)
+ if (sources.isEmpty &&
+ !original.expressions.exists(_.containsPattern(PLAN_EXPRESSION))) {
+ return Set.empty
+ }
+
+ // Union and reused stages can expose different ExprIds for the same
output positions.
+ original match {
+ case _: UnionExec | _: ReusedExchangeExec | _: QueryStageExec |
+ _: AdaptiveSparkPlanExec =>
+ plan.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (children.zip(childSources).exists { case (child, ids) =>
+ child.output.lift(ordinal).exists(a => ids.contains(a.exprId))
+ }) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+
+ val named = original.expressions.flatMap(_.collect { case e:
NamedExpression => e })
+ var changed = true
+ while (changed) {
+ val previous = sources
+ named.foreach { expression =>
+ if (hasStructSubquerySource(expression, sources)) sources +=
expression.exprId
+ }
+ original match {
+ case aggregate: BaseAggregateExec =>
+
aggregate.aggregateExpressions.zip(aggregate.aggregateAttributes).foreach {
+ case (expression, output) =>
+ val function = expression.aggregateFunction
+ if (hasStructSubquerySource(expression, sources) ||
+ function.inputAggBufferAttributes.exists(a =>
sources.contains(a.exprId))) {
+ sources += output.exprId
+ sources ++= function.aggBufferAttributes.map(_.exprId)
+ sources ++= function.inputAggBufferAttributes.map(_.exprId)
+ }
+ }
+ case generate: GenerateExec if
hasStructSubquerySource(generate.generator, sources) =>
+ // Generators expose fresh attributes rather than NamedExpression
aliases.
+ sources ++= generate.generatorOutput.map(_.exprId)
+ case expand: ExpandExec =>
+ expand.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (expand.projections.exists(p =>
hasStructSubquerySource(p(ordinal), sources))) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+ changed = previous != sources
+ }
+
+ plan.setTagValue(structSubqueryInputs, sources)
+ val outputs =
plan.output.iterator.map(_.exprId).filter(sources.contains).toSet
+ remember(plan, outputs)
+ outputs
+ }
+
+ val _ = visit(root)
+ }
+
+ /** Keep the lineage local to this conversion, even when an attribute was
copied or rebound. */
+ private[comet] def withPlanInputs[T](plan: SparkPlan)(f: => T): T = {
+ val inputs = plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty)
+ currentStructSubqueryInputs.withValue(inputs) {
+ currentPlanHasStructSubquery.withValue(
+ plan.getTagValue(structSubqueryContext).getOrElse(false))(f)
+ }
+ }
+
+ private val calendarConsumerFallbackReason =
+ "Calendar operations on struct scalar subqueries require Spark because
their runtime values " +
+ "may exceed the native calendar range"
+
+ private val timezoneConsumerFallbackReason =
+ "Timezone-dependent scalar subquery operations require Spark for non-fixed
timezones"
+
+ private val dateUnixTimestampFallbackReason =
+ "Date scalar subqueries in unix_timestamp require Spark because their
runtime values " +
+ "may exceed the native calendar range"
+
+ private def containsCalendarType(dt: DataType): Boolean = dt match {
+ case DateType | TimestampType | TimestampNTZType => true
+ case s: StructType => s.fields.exists(f =>
containsCalendarType(f.dataType))
+ case ArrayType(elementType, _) => containsCalendarType(elementType)
+ case MapType(keyType, valueType, _) =>
+ containsCalendarType(keyType) || containsCalendarType(valueType)
+ case _ => false
+ }
+
+ private def containsVariableWidth(dt: DataType): Boolean = dt match {
+ case StringType | BinaryType => true
+ case s: StructType => s.fields.exists(f =>
containsVariableWidth(f.dataType))
+ case ArrayType(elementType, _) => containsVariableWidth(elementType)
+ case _ => false
+ }
+
+ private def hasVariableWidthStructSubquery(expr: Expression): Boolean =
+ expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
+ subquery.dataType.isInstanceOf[StructType] &&
containsVariableWidth(subquery.dataType)
+ case _ => false
+ }
+
+ /** Opaque JVM kernels cannot participate in the native consumer's bounded
evaluation. */
+ private[serde] def dispatchedConsumerFallbackReason(
+ expr: Expression,
+ proto: ExprOuterClass.Expr): Option[String] = {
+ def containsDispatch(message: Message): Boolean = message match {
+ case e: ExprOuterClass.Expr if e.hasJvmScalarUdf => true
+ case other =>
+ other.getAllFields.values().asScala.exists {
+ case child: Message => containsDispatch(child)
+ case children: java.util.List[_] =>
+ children.asScala.exists {
+ case child: Message => containsDispatch(child)
+ case _ => false
+ }
+ case _ => false
+ }
+ }
+ if (hasVariableWidthStructSubquery(expr) && containsDispatch(proto)) {
+ Some("JVM-dispatched consumers of variable-width struct scalar
subqueries require Spark")
+ } else {
+ None
+ }
+ }
+
+ private def isTimestamp(dt: DataType): Boolean =
+ dt == TimestampType || dt == TimestampNTZType
+
+ private def isEpochCast(from: DataType, to: DataType): Boolean =
+ (isTimestamp(from) && to == LongType) || (from == LongType &&
isTimestamp(to))
+
+ private def hasStructSubquerySource(expr: Expression, inputs: Set[ExprId]):
Boolean =
+ expr.references.exists(a => inputs.contains(a.exprId)) ||
+ (expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
subquery.dataType.isInstanceOf[StructType]
+ case _ => false
+ })
+
+ private def hasStructSubquery(expr: Expression): Boolean =
+ hasStructSubquerySource(expr, currentStructSubqueryInputs.value)
+
+ private def hasStructSubqueryContext(expr: Expression): Boolean =
+ currentPlanHasStructSubquery.value || hasStructSubquery(expr)
+
+ private def clockFallbackReason(
+ child: Expression,
+ timeZoneId: Option[String]): Option[String] = {
+ if (child.dataType == TimestampNTZType ||
CometTimeZone.isFixedOffset(timeZoneId)) {
+ None
+ } else {
+ Some(timezoneConsumerFallbackReason)
+ }
+ }
+
+ /**
+ * Admit only reviewed consumers of dates and timestamps on newly supported
struct-subquery
+ * paths. Unknown consumers stay on Spark by default, including expressions
whose result is not
+ * a calendar type. Transport, comparisons and epoch arithmetic do not
interpret a calendar.
+ *
+ * Check the whole expression before serialization or codegen dispatch,
including wrappers and
+ * whole-struct casts. A physical subquery may be reused, but it is still an
+ * [[ExecSubqueryExpression]]; its result must never be evaluated while
planning this check.
+ */
+ private[serde] def consumerFallbackReason(expr: Expression): Option[String]
= {
+ // Skip paths without struct subqueries. A newly admitted calendar
consumer can be a
+ // sibling or ancestor of a struct-producing operator without using its
result directly.
+ // Aliases still carry value provenance through the owning plan's ExprIds.
+ val derivedInput =
+ expr.references.exists(a =>
currentStructSubqueryInputs.value.contains(a.exprId))
+ if (!currentPlanHasStructSubquery.value && !derivedInput &&
+ (!expr.containsPattern(PLAN_EXPRESSION) ||
!ExecSubqueryExpression.hasSubquery(expr))) {
+ return None
+ }
+
+ // Native consumers may change batch boundaries to avoid repeating a large
scalar field.
+ // Stateful consumers must retain Spark's evaluation order and their
pre-existing fallback.
+ if (!expr.deterministic && hasVariableWidthStructSubquery(expr)) {
+ return Some(
+ "Nondeterministic consumers of variable-width struct scalar subqueries
require Spark")
+ }
+
+ expr match {
+ // Dynamic nested IN stops after a batch-wide match in native execution.
Keep row-dependent
+ // candidates on Spark rather than change ANSI/shared-expression
evaluation by slicing.
+ // Literal candidates use the separate static IN implementation.
Constant membership also
+ // needs sharing authorization: Spark may eagerly evaluate a shared,
failing candidate.
+ case in: In
+ if hasStructSubqueryContext(in) &&
+ (in.value.dataType.isInstanceOf[StructType] ||
+ in.value.dataType.isInstanceOf[ArrayType]) &&
+ containsVariableWidth(in.value.dataType) &&
+ in.list.exists(!_.isInstanceOf[Literal]) &&
+ (in.references.nonEmpty ||
!QueryPlanSerde.canSkipEvaluation(in.list)) =>
+ return Some("Nested membership of struct scalar subqueries requires
Spark")
+ case _ =>
+ }
+
+ val touchesCalendar =
+ containsCalendarType(expr.dataType) ||
+ expr.children.exists(child => containsCalendarType(child.dataType))
+
+ val reason = expr match {
+ // Preserve the existing guard for plain scalar date subqueries as well
as struct fields.
+ case unix: UnixTimestamp
+ if unix.timeExp.dataType == DateType &&
+ (ExecSubqueryExpression.hasSubquery(unix.timeExp) ||
hasStructSubqueryContext(
+ unix.timeExp)) =>
+ Some(dateUnixTimestampFallbackReason)
+ // Retain timezone protection for plain scalar subqueries too.
+ case hour: Hour => clockFallbackReason(hour.child, hour.timeZoneId)
+ case minute: Minute => clockFallbackReason(minute.child,
minute.timeZoneId)
+ case second: Second => clockFallbackReason(second.child,
second.timeZoneId)
+ case _ if !hasStructSubqueryContext(expr) || !touchesCalendar => None
Review Comment:
[P2] Preserve generated Spark `concat` semantics for newly admitted
struct-subquery consumers. Spark’s generated string projection stops evaluating
arguments once one is NULL. Native `concat` evaluates a later failing cast on
that row. Account for Spark’s active execution mode when masking arguments, or
retain fallback.
Evidence: With ANSI and whole-stage codegen enabled, use
`items(id,txt)=[(1,'bad'),(2,'2')]`, `nums(k)=[0,1]`, and `z=(SELECT
max(named_struct('f',k)) FROM nums WHERE k<0).f`. Run `SELECT id,
concat(IF(id=1,CAST(z AS STRING),CAST(id AS STRING)),CAST(CAST(txt AS INT) AS
STRING)) FROM items`, substituting `z`. Spark and base returned
`(1,NULL),(2,'22')`. Head raised `CAST_INVALID_INPUT` on `'bad'`. With
whole-stage codegen disabled and `spark.sql.codegen.factoryMode=NO_CODEGEN`,
all three raised the error.
##########
native/spark-expr/src/scalar_subquery_consumer.rs:
##########
@@ -0,0 +1,358 @@
+// 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.
+
+use crate::array_funcs::NestedPredicate;
+use crate::{
+ ArrayInsert, AtLeastNNonNulls, CaseWhenExpr, Cast, CheckedBinaryExpr,
CreateNamedStruct,
+ FromJson, GetArrayStructFields, GetStructField, IfExpr, ListExtract,
NegativeExpr,
+ NormalizeNestedFloats, NullShortCircuit, RLike, SparkArraysZipFunc,
SparkComparison, Subquery,
+ ToCsv, ToJson,
+};
+use arrow::array::{new_empty_array, Array, ArrayData, ArrayRef};
+use arrow::compute::concat;
+use arrow::datatypes::{DataType, FieldRef, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::{exec_err, internal_err, Result, ScalarValue};
+use datafusion::logical_expr::{ColumnarValue, Volatility};
+use datafusion::physical_expr::expressions::{
+ BinaryExpr, CastExpr, Column, InListExpr, IsNotNullExpr, IsNullExpr,
LikeExpr, Literal, NotExpr,
+};
+use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr};
+use std::fmt::{Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+/// Evaluates scalar-struct consumers before repeating large variable-width
fields.
+///
+/// GetStructField retains its batch-sized array contract. Constant consumers
run on one row;
+/// pure row-dependent consumers run on bounded slices before their results
are concatenated.
+/// Nothing is evaluated during planning, preserving conditional and ANSI NULL
short-circuiting.
+#[derive(Debug, Eq)]
+pub struct ScalarSubqueryConsumer {
+ inner: Arc<dyn PhysicalExpr>,
+ constant: bool,
+ max_rows: usize,
+}
+
+// Limit repeated scalar input payload, not the size of the user's final
output. A single scalar
+// can exceed this budget; in that case consume one row at a time.
+const SCALAR_INPUT_BYTES: usize = 1024 * 1024;
+
+impl ScalarSubqueryConsumer {
+ pub fn wrap(inner: Arc<dyn PhysicalExpr>) -> Arc<dyn PhysicalExpr> {
+ // Keep literals and direct field chains visible so a narrow field can
be selected
+ // before broadcasting. A field read over a computed struct is itself
a consumer.
+ if inner.is::<Literal>()
+ || inner.is::<Self>()
+ || inner
+ .downcast_ref::<GetStructField>()
+ .is_some_and(|field| field.literal_field().is_some())
+ {
+ return inner;
+ }
+ if let Some(properties) = consumer_properties(&inner) {
+ if properties.has_scalar_struct
+ && (properties.constant || properties.bytes != 0 ||
properties.unbounded_output)
+ {
+ return Arc::new(Self {
+ inner,
+ constant: properties.constant,
+ max_rows: if properties.unbounded_output {
+ 1
+ } else {
+ (SCALAR_INPUT_BYTES / properties.bytes.max(1)).max(1)
+ },
+ });
+ }
+ }
+ inner
+ }
+}
+
+struct ConsumerProperties {
+ constant: bool,
+ has_scalar_struct: bool,
+ bytes: usize,
+ unbounded_output: bool,
+}
+
+/// Explicitly recognize pure row-local trees. Unknown nodes and volatile
functions cannot safely
+/// be evaluated on a different batch. Columns require sliced evaluation.
+fn consumer_properties(expr: &Arc<dyn PhysicalExpr>) ->
Option<ConsumerProperties> {
+ if let Some(value) = expr
+ .downcast_ref::<GetStructField>()
+ .and_then(GetStructField::literal_field)
+ {
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: true,
+ bytes: variable_bytes(&value.to_data()),
+ unbounded_output: false,
+ });
+ }
+ if let Some(literal) = expr.downcast_ref::<Literal>() {
+ let bytes = match literal.value() {
+ ScalarValue::Utf8(Some(value)) |
ScalarValue::LargeUtf8(Some(value)) => value.len(),
+ ScalarValue::Binary(Some(value)) |
ScalarValue::LargeBinary(Some(value)) => value.len(),
+ ScalarValue::Struct(value) => variable_bytes(&value.to_data()),
+ _ => 0,
+ };
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: matches!(literal.value(),
ScalarValue::Struct(_)),
+ bytes,
+ unbounded_output: false,
+ });
+ }
+ // Spark materializes and registers this execution-context value before
native execution.
+ // Lookup still happens at runtime; it is independent of the current input
row or batch.
+ if expr.is::<Subquery>() {
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: false,
+ bytes: 0,
+ unbounded_output: false,
+ });
+ }
+ if expr.is::<Column>() {
+ return Some(ConsumerProperties {
+ constant: false,
+ has_scalar_struct: false,
+ bytes: 0,
+ unbounded_output: false,
+ });
+ }
+ if let Some(expr) = expr.downcast_ref::<ScalarSubqueryConsumer>() {
+ return consumer_properties(&expr.inner);
+ }
+ if let Some(expr) = expr.downcast_ref::<NullShortCircuit>() {
+ return consumer_properties(expr.inner());
+ }
+ if let Some(fun) = expr.downcast_ref::<ScalarFunctionExpr>() {
+ if fun.fun().signature().volatility != Volatility::Immutable {
+ return None;
+ }
+ } else if !(expr.is::<AtLeastNNonNulls>()
+ || expr.is::<ArrayInsert>()
+ || expr.is::<GetArrayStructFields>()
+ || expr.is::<SparkArraysZipFunc>()
+ || expr.is::<NestedPredicate>()
+ || expr.is::<NormalizeNestedFloats>()
+ || expr.is::<FromJson>()
+ || expr.is::<ToCsv>()
+ || expr.is::<GetStructField>()
+ || expr.is::<CastExpr>()
+ || expr.is::<Cast>()
+ || expr.is::<BinaryExpr>()
+ || expr.is::<CheckedBinaryExpr>()
+ || expr.is::<NegativeExpr>()
+ || expr.is::<SparkComparison>()
+ || expr.is::<IsNullExpr>()
+ || expr.is::<IsNotNullExpr>()
+ || expr.is::<NotExpr>()
+ || expr.is::<InListExpr>()
+ || expr.is::<LikeExpr>()
+ || expr.is::<RLike>()
+ || expr.is::<IfExpr>()
+ || expr.is::<CaseWhenExpr>()
+ || expr.is::<CreateNamedStruct>()
+ || expr.is::<ListExtract>()
+ || expr.is::<ToJson>())
+ {
+ return None;
+ }
+ let mut properties = ConsumerProperties {
+ constant: true,
+ has_scalar_struct: false,
+ bytes: 0,
+ // Input payload does not bound outputs such as repeat, padding, JSON
or growing lists.
+ // Propagate this even from constant children: a row-dependent IF can
select them for
+ // a whole batch before its parent reduces the result.
+ unbounded_output: expr
+ .downcast_ref::<ScalarFunctionExpr>()
+ .map(ScalarFunctionExpr::return_type)
+ .or_else(|| expr.downcast_ref::<Cast>().map(|cast|
&cast.data_type))
+ .or_else(||
expr.downcast_ref::<CastExpr>().map(CastExpr::cast_type))
+ .is_some_and(|dt| {
+ matches!(
+ dt,
+ DataType::Utf8
+ | DataType::LargeUtf8
+ | DataType::Utf8View
+ | DataType::Binary
+ | DataType::LargeBinary
+ | DataType::BinaryView
+ ) || dt.is_nested()
+ })
+ || expr.is::<ToJson>()
+ || expr.is::<ToCsv>()
+ || expr.is::<FromJson>()
+ || expr.is::<ArrayInsert>()
+ || expr.is::<SparkArraysZipFunc>()
+ || expr
+ .downcast_ref::<ListExtract>()
+ .is_some_and(|e| e.default_value().is_some()),
+ };
+ let default_value = expr
+ .downcast_ref::<ListExtract>()
+ .and_then(ListExtract::default_value);
+ for child in expr.children().into_iter().chain(default_value) {
+ let child = consumer_properties(child)?;
Review Comment:
[P2] Keep a deterministic condition from disabling width protection for an
independent branch. Integral `DIV` introduces a `CheckOverflow` node outside
the recognized set. The `?` propagates that unknown child through the entire
`IF` and its reducing parent, allowing a wide field to expand across the full
batch. Handle this row-local node and retain a bounded path or fallback when a
child cannot be classified.
Evidence: Use two 262,144-byte strings in Parquet `wide(s)` and IDs 0–8191
in one Parquet batch `o`, with ANSI and reuse on and AQE off. Run `SELECT
id,octet_length(IF(id DIV 2 >= 0,(SELECT max(s) FROM wide),(SELECT min(s) FROM
wide))) FROM o`. Spark and base returned 8,192 lengths of `262144`. Head
panicked with `offset overflow` in `GetStructField::broadcast_field`. Changing
only the condition to `id >= 0` made head return all expected lengths.
##########
native/spark-expr/src/scalar_subquery_consumer.rs:
##########
@@ -0,0 +1,358 @@
+// 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.
+
+use crate::array_funcs::NestedPredicate;
+use crate::{
+ ArrayInsert, AtLeastNNonNulls, CaseWhenExpr, Cast, CheckedBinaryExpr,
CreateNamedStruct,
+ FromJson, GetArrayStructFields, GetStructField, IfExpr, ListExtract,
NegativeExpr,
+ NormalizeNestedFloats, NullShortCircuit, RLike, SparkArraysZipFunc,
SparkComparison, Subquery,
+ ToCsv, ToJson,
+};
+use arrow::array::{new_empty_array, Array, ArrayData, ArrayRef};
+use arrow::compute::concat;
+use arrow::datatypes::{DataType, FieldRef, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::{exec_err, internal_err, Result, ScalarValue};
+use datafusion::logical_expr::{ColumnarValue, Volatility};
+use datafusion::physical_expr::expressions::{
+ BinaryExpr, CastExpr, Column, InListExpr, IsNotNullExpr, IsNullExpr,
LikeExpr, Literal, NotExpr,
+};
+use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr};
+use std::fmt::{Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+/// Evaluates scalar-struct consumers before repeating large variable-width
fields.
+///
+/// GetStructField retains its batch-sized array contract. Constant consumers
run on one row;
+/// pure row-dependent consumers run on bounded slices before their results
are concatenated.
+/// Nothing is evaluated during planning, preserving conditional and ANSI NULL
short-circuiting.
+#[derive(Debug, Eq)]
+pub struct ScalarSubqueryConsumer {
+ inner: Arc<dyn PhysicalExpr>,
+ constant: bool,
+ max_rows: usize,
+}
+
+// Limit repeated scalar input payload, not the size of the user's final
output. A single scalar
+// can exceed this budget; in that case consume one row at a time.
+const SCALAR_INPUT_BYTES: usize = 1024 * 1024;
+
+impl ScalarSubqueryConsumer {
+ pub fn wrap(inner: Arc<dyn PhysicalExpr>) -> Arc<dyn PhysicalExpr> {
+ // Keep literals and direct field chains visible so a narrow field can
be selected
+ // before broadcasting. A field read over a computed struct is itself
a consumer.
+ if inner.is::<Literal>()
+ || inner.is::<Self>()
+ || inner
+ .downcast_ref::<GetStructField>()
+ .is_some_and(|field| field.literal_field().is_some())
+ {
+ return inner;
+ }
+ if let Some(properties) = consumer_properties(&inner) {
+ if properties.has_scalar_struct
+ && (properties.constant || properties.bytes != 0 ||
properties.unbounded_output)
+ {
+ return Arc::new(Self {
+ inner,
+ constant: properties.constant,
+ max_rows: if properties.unbounded_output {
+ 1
Review Comment:
[P2] Keep integer-to-string casts batched when their output has a known
small bound. Formatting a signed `BIGINT` needs at most 20 bytes. Setting
`max_rows` to one makes the cast and its downstream length kernel run once per
row, followed by concatenation. Use the byte bound to choose a safe slice size.
Evidence: Use one Parquet partition with `payload.k` holding `BIGINT` IDs
0–262143 and `src(n)=[262144]`. Run `SELECT octet_length(CAST(payload.k+(SELECT
max(n) FROM src) AS STRING)),(SELECT min(n) FROM src) FROM t` with reuse on,
AQE off, and batch size 8,192. Both revisions returned checksum `68721049600`
when both columns were consumed. On one Spark 4.1.3 worker, five post-warm-up
actions had a median of 114 ms on base and 2,213 ms on head.
##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,362 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.jdk.CollectionConverters._
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
BoundReference, CaseWhen, Cast, Coalesce, CreateArray, CreateMap,
CreateNamedStruct, DateFromUnixDate, DayOfWeek, ElementAt, EqualNullSafe,
EqualTo, Expression, ExprId, GetArrayItem, GetArrayStructFields, GetMapValue,
GetStructField, GreaterThan, GreaterThanOrEqual, Greatest, Hour, If, In, InSet,
IsNotNull, IsNull, KnownNotNull, KnownNullable, Least, LessThan,
LessThanOrEqual, Literal, Minute, NamedExpression, PreciseTimestampConversion,
Second, SortOrder, ToUnixTimestamp, TryEval, UnixDate, UnixTimestamp, WeekDay}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.comet.CometExec
+import org.apache.spark.sql.execution.{ExecSubqueryExpression, ExpandExec,
GenerateExec, ScalarSubquery, SparkPlan, UnionExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
QueryStageExec}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types._
+
+import com.google.protobuf.Message
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
import org.apache.comet.serde.QueryPlanSerde.{serializeDataType,
supportedDataType}
object CometScalarSubquery extends CometExpressionSerde[ScalarSubquery] {
+ private val structSubqueryInputs =
+ new TreeNodeTag[Set[ExprId]]("CometStructScalarSubqueryInputs")
+ private val structSubqueryContext =
+ new TreeNodeTag[Boolean]("CometStructScalarSubqueryContext")
+ private val currentStructSubqueryInputs = new
DynamicVariable[Set[ExprId]](Set.empty)
+ private val currentPlanHasStructSubquery = new
DynamicVariable[Boolean](false)
+
+ /**
+ * Trace struct scalar values before native conversion replaces their
producing projections.
+ * Attribute object identity is insufficient: aliases, nullable join outputs
and reused stages
+ * can create fresh attributes. Keep provenance by ExprId on the owning plan
instead.
+ *
+ * This is recomputed on every rule application, including the plans hidden
by AQE/reuse nodes.
+ * No provenance is inferred from a column's data type or carried in a
global ExprId registry.
+ */
+ private[comet] def tagPlanInputs(root: SparkPlan): Unit = {
+ val visited = new java.util.IdentityHashMap[SparkPlan, Set[ExprId]]()
+ def remember(plan: SparkPlan, sources: Set[ExprId]): Unit = {
+ val _ = visited.put(plan, sources)
+ }
+
+ def visit(plan: SparkPlan): Set[ExprId] = {
+ if (visited.containsKey(plan)) return visited.get(plan)
+ remember(plan, Set.empty)
+ plan.unsetTagValue(structSubqueryInputs)
+ plan.unsetTagValue(structSubqueryContext)
+
+ val original = plan match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val children = plan match {
+ case reused: ReusedExchangeExec => Seq(reused.child)
+ case stage: QueryStageExec => Seq(stage.plan)
+ case adaptive: AdaptiveSparkPlanExec => Seq(adaptive.executedPlan)
+ case comet: CometExec if comet.children.isEmpty =>
Seq(comet.originalPlan)
+ case other => other.children
+ }
+ val childSources = children.map(visit)
+ var sources = childSources.flatten.toSet
+ // A struct subquery can enable native conversion of both its operator
and ancestors.
+ // Keep that context even when a later projection drops the struct:
sibling/ancestor
+ // calendar consumers can still receive ordinary columns outside the
native range.
+ val hasStructContext =
+ children.exists(_.getTagValue(structSubqueryContext).contains(true)) ||
+ original.expressions.exists(hasStructSubquerySource(_, sources))
+ plan.setTagValue(structSubqueryContext, hasStructContext)
+ if (sources.isEmpty &&
+ !original.expressions.exists(_.containsPattern(PLAN_EXPRESSION))) {
+ return Set.empty
+ }
+
+ // Union and reused stages can expose different ExprIds for the same
output positions.
+ original match {
+ case _: UnionExec | _: ReusedExchangeExec | _: QueryStageExec |
+ _: AdaptiveSparkPlanExec =>
+ plan.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (children.zip(childSources).exists { case (child, ids) =>
+ child.output.lift(ordinal).exists(a => ids.contains(a.exprId))
+ }) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+
+ val named = original.expressions.flatMap(_.collect { case e:
NamedExpression => e })
+ var changed = true
+ while (changed) {
+ val previous = sources
+ named.foreach { expression =>
+ if (hasStructSubquerySource(expression, sources)) sources +=
expression.exprId
+ }
+ original match {
+ case aggregate: BaseAggregateExec =>
+
aggregate.aggregateExpressions.zip(aggregate.aggregateAttributes).foreach {
+ case (expression, output) =>
+ val function = expression.aggregateFunction
+ if (hasStructSubquerySource(expression, sources) ||
+ function.inputAggBufferAttributes.exists(a =>
sources.contains(a.exprId))) {
+ sources += output.exprId
+ sources ++= function.aggBufferAttributes.map(_.exprId)
+ sources ++= function.inputAggBufferAttributes.map(_.exprId)
+ }
+ }
+ case generate: GenerateExec if
hasStructSubquerySource(generate.generator, sources) =>
+ // Generators expose fresh attributes rather than NamedExpression
aliases.
+ sources ++= generate.generatorOutput.map(_.exprId)
+ case expand: ExpandExec =>
+ expand.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (expand.projections.exists(p =>
hasStructSubquerySource(p(ordinal), sources))) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+ changed = previous != sources
+ }
+
+ plan.setTagValue(structSubqueryInputs, sources)
+ val outputs =
plan.output.iterator.map(_.exprId).filter(sources.contains).toSet
+ remember(plan, outputs)
+ outputs
+ }
+
+ val _ = visit(root)
+ }
+
+ /** Keep the lineage local to this conversion, even when an attribute was
copied or rebound. */
+ private[comet] def withPlanInputs[T](plan: SparkPlan)(f: => T): T = {
+ val inputs = plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty)
+ currentStructSubqueryInputs.withValue(inputs) {
+ currentPlanHasStructSubquery.withValue(
+ plan.getTagValue(structSubqueryContext).getOrElse(false))(f)
+ }
+ }
+
+ private val calendarConsumerFallbackReason =
+ "Calendar operations on struct scalar subqueries require Spark because
their runtime values " +
+ "may exceed the native calendar range"
+
+ private val timezoneConsumerFallbackReason =
+ "Timezone-dependent scalar subquery operations require Spark for non-fixed
timezones"
+
+ private val dateUnixTimestampFallbackReason =
+ "Date scalar subqueries in unix_timestamp require Spark because their
runtime values " +
+ "may exceed the native calendar range"
+
+ private def containsCalendarType(dt: DataType): Boolean = dt match {
+ case DateType | TimestampType | TimestampNTZType => true
+ case s: StructType => s.fields.exists(f =>
containsCalendarType(f.dataType))
+ case ArrayType(elementType, _) => containsCalendarType(elementType)
+ case MapType(keyType, valueType, _) =>
+ containsCalendarType(keyType) || containsCalendarType(valueType)
+ case _ => false
+ }
+
+ private def containsVariableWidth(dt: DataType): Boolean = dt match {
+ case StringType | BinaryType => true
+ case s: StructType => s.fields.exists(f =>
containsVariableWidth(f.dataType))
+ case ArrayType(elementType, _) => containsVariableWidth(elementType)
+ case _ => false
+ }
+
+ private def hasVariableWidthStructSubquery(expr: Expression): Boolean =
+ expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
+ subquery.dataType.isInstanceOf[StructType] &&
containsVariableWidth(subquery.dataType)
+ case _ => false
+ }
+
+ /** Opaque JVM kernels cannot participate in the native consumer's bounded
evaluation. */
+ private[serde] def dispatchedConsumerFallbackReason(
+ expr: Expression,
+ proto: ExprOuterClass.Expr): Option[String] = {
+ def containsDispatch(message: Message): Boolean = message match {
+ case e: ExprOuterClass.Expr if e.hasJvmScalarUdf => true
+ case other =>
+ other.getAllFields.values().asScala.exists {
+ case child: Message => containsDispatch(child)
+ case children: java.util.List[_] =>
+ children.asScala.exists {
+ case child: Message => containsDispatch(child)
+ case _ => false
+ }
+ case _ => false
+ }
+ }
+ if (hasVariableWidthStructSubquery(expr) && containsDispatch(proto)) {
+ Some("JVM-dispatched consumers of variable-width struct scalar
subqueries require Spark")
+ } else {
+ None
+ }
+ }
+
+ private def isTimestamp(dt: DataType): Boolean =
+ dt == TimestampType || dt == TimestampNTZType
+
+ private def isEpochCast(from: DataType, to: DataType): Boolean =
+ (isTimestamp(from) && to == LongType) || (from == LongType &&
isTimestamp(to))
+
+ private def hasStructSubquerySource(expr: Expression, inputs: Set[ExprId]):
Boolean =
+ expr.references.exists(a => inputs.contains(a.exprId)) ||
+ (expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
subquery.dataType.isInstanceOf[StructType]
+ case _ => false
+ })
+
+ private def hasStructSubquery(expr: Expression): Boolean =
+ hasStructSubquerySource(expr, currentStructSubqueryInputs.value)
+
+ private def hasStructSubqueryContext(expr: Expression): Boolean =
+ currentPlanHasStructSubquery.value || hasStructSubquery(expr)
+
+ private def clockFallbackReason(
+ child: Expression,
+ timeZoneId: Option[String]): Option[String] = {
+ if (child.dataType == TimestampNTZType ||
CometTimeZone.isFixedOffset(timeZoneId)) {
+ None
+ } else {
+ Some(timezoneConsumerFallbackReason)
+ }
+ }
+
+ /**
+ * Admit only reviewed consumers of dates and timestamps on newly supported
struct-subquery
+ * paths. Unknown consumers stay on Spark by default, including expressions
whose result is not
+ * a calendar type. Transport, comparisons and epoch arithmetic do not
interpret a calendar.
+ *
+ * Check the whole expression before serialization or codegen dispatch,
including wrappers and
+ * whole-struct casts. A physical subquery may be reused, but it is still an
+ * [[ExecSubqueryExpression]]; its result must never be evaluated while
planning this check.
+ */
+ private[serde] def consumerFallbackReason(expr: Expression): Option[String]
= {
+ // Skip paths without struct subqueries. A newly admitted calendar
consumer can be a
+ // sibling or ancestor of a struct-producing operator without using its
result directly.
+ // Aliases still carry value provenance through the owning plan's ExprIds.
+ val derivedInput =
+ expr.references.exists(a =>
currentStructSubqueryInputs.value.contains(a.exprId))
+ if (!currentPlanHasStructSubquery.value && !derivedInput &&
+ (!expr.containsPattern(PLAN_EXPRESSION) ||
!ExecSubqueryExpression.hasSubquery(expr))) {
+ return None
+ }
+
+ // Native consumers may change batch boundaries to avoid repeating a large
scalar field.
+ // Stateful consumers must retain Spark's evaluation order and their
pre-existing fallback.
+ if (!expr.deterministic && hasVariableWidthStructSubquery(expr)) {
+ return Some(
+ "Nondeterministic consumers of variable-width struct scalar subqueries
require Spark")
+ }
+
+ expr match {
+ // Dynamic nested IN stops after a batch-wide match in native execution.
Keep row-dependent
+ // candidates on Spark rather than change ANSI/shared-expression
evaluation by slicing.
+ // Literal candidates use the separate static IN implementation.
Constant membership also
+ // needs sharing authorization: Spark may eagerly evaluate a shared,
failing candidate.
+ case in: In
+ if hasStructSubqueryContext(in) &&
+ (in.value.dataType.isInstanceOf[StructType] ||
+ in.value.dataType.isInstanceOf[ArrayType]) &&
+ containsVariableWidth(in.value.dataType) &&
+ in.list.exists(!_.isInstanceOf[Literal]) &&
+ (in.references.nonEmpty ||
!QueryPlanSerde.canSkipEvaluation(in.list)) =>
+ return Some("Nested membership of struct scalar subqueries requires
Spark")
+ case _ =>
+ }
+
+ val touchesCalendar =
+ containsCalendarType(expr.dataType) ||
+ expr.children.exists(child => containsCalendarType(child.dataType))
+
+ val reason = expr match {
+ // Preserve the existing guard for plain scalar date subqueries as well
as struct fields.
+ case unix: UnixTimestamp
+ if unix.timeExp.dataType == DateType &&
+ (ExecSubqueryExpression.hasSubquery(unix.timeExp) ||
hasStructSubqueryContext(
+ unix.timeExp)) =>
+ Some(dateUnixTimestampFallbackReason)
+ // Retain timezone protection for plain scalar subqueries too.
+ case hour: Hour => clockFallbackReason(hour.child, hour.timeZoneId)
+ case minute: Minute => clockFallbackReason(minute.child,
minute.timeZoneId)
+ case second: Second => clockFallbackReason(second.child,
second.timeZoneId)
+ case _ if !hasStructSubqueryContext(expr) || !touchesCalendar => None
Review Comment:
[P2] Preserve nullable operand order when admitting non-calendar consumers.
Addition evaluates a failing right operand after a NULL left operand, and `DIV`
evaluates its numerator after a NULL divisor. I also reproduced eager later
arguments in `startswith` and `substring`. These paths can abort a successful
query or advance a stateful expression and change a later row. Apply each
operator’s Spark evaluation order or retain fallback.
Evidence: Use one-file Parquet `items(id,txt)=[(1,'bad'),(2,'2')]`,
`nums(k)=[0,1]`, ANSI on, and AQE off. Substitute `z=(SELECT
max(named_struct('f',k)) FROM nums WHERE k<0).f`. Both `SELECT z+CAST(txt AS
INT) FROM items` and `SELECT CAST(txt AS INT) DIV z FROM items` returned two
NULLs on Spark and base. Both raised `CAST_INVALID_INPUT` on head. On one
partition, `SELECT id, IF(id=1,z,id)+monotonically_increasing_id() FROM items`
returned `(1,NULL),(2,2)` on the controls and `(1,NULL),(2,3)` on head. The
ordinary equality control returned NULLs on all three.
##########
native/spark-expr/src/scalar_subquery_consumer.rs:
##########
@@ -0,0 +1,358 @@
+// 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.
+
+use crate::array_funcs::NestedPredicate;
+use crate::{
+ ArrayInsert, AtLeastNNonNulls, CaseWhenExpr, Cast, CheckedBinaryExpr,
CreateNamedStruct,
+ FromJson, GetArrayStructFields, GetStructField, IfExpr, ListExtract,
NegativeExpr,
+ NormalizeNestedFloats, NullShortCircuit, RLike, SparkArraysZipFunc,
SparkComparison, Subquery,
+ ToCsv, ToJson,
+};
+use arrow::array::{new_empty_array, Array, ArrayData, ArrayRef};
+use arrow::compute::concat;
+use arrow::datatypes::{DataType, FieldRef, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::{exec_err, internal_err, Result, ScalarValue};
+use datafusion::logical_expr::{ColumnarValue, Volatility};
+use datafusion::physical_expr::expressions::{
+ BinaryExpr, CastExpr, Column, InListExpr, IsNotNullExpr, IsNullExpr,
LikeExpr, Literal, NotExpr,
+};
+use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr};
+use std::fmt::{Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+/// Evaluates scalar-struct consumers before repeating large variable-width
fields.
+///
+/// GetStructField retains its batch-sized array contract. Constant consumers
run on one row;
+/// pure row-dependent consumers run on bounded slices before their results
are concatenated.
+/// Nothing is evaluated during planning, preserving conditional and ANSI NULL
short-circuiting.
+#[derive(Debug, Eq)]
+pub struct ScalarSubqueryConsumer {
+ inner: Arc<dyn PhysicalExpr>,
+ constant: bool,
+ max_rows: usize,
+}
+
+// Limit repeated scalar input payload, not the size of the user's final
output. A single scalar
+// can exceed this budget; in that case consume one row at a time.
+const SCALAR_INPUT_BYTES: usize = 1024 * 1024;
+
+impl ScalarSubqueryConsumer {
+ pub fn wrap(inner: Arc<dyn PhysicalExpr>) -> Arc<dyn PhysicalExpr> {
+ // Keep literals and direct field chains visible so a narrow field can
be selected
+ // before broadcasting. A field read over a computed struct is itself
a consumer.
+ if inner.is::<Literal>()
+ || inner.is::<Self>()
+ || inner
+ .downcast_ref::<GetStructField>()
+ .is_some_and(|field| field.literal_field().is_some())
+ {
+ return inner;
+ }
+ if let Some(properties) = consumer_properties(&inner) {
+ if properties.has_scalar_struct
+ && (properties.constant || properties.bytes != 0 ||
properties.unbounded_output)
+ {
+ return Arc::new(Self {
+ inner,
+ constant: properties.constant,
+ max_rows: if properties.unbounded_output {
+ 1
+ } else {
+ (SCALAR_INPUT_BYTES / properties.bytes.max(1)).max(1)
+ },
+ });
+ }
+ }
+ inner
+ }
+}
+
+struct ConsumerProperties {
+ constant: bool,
+ has_scalar_struct: bool,
+ bytes: usize,
+ unbounded_output: bool,
+}
+
+/// Explicitly recognize pure row-local trees. Unknown nodes and volatile
functions cannot safely
+/// be evaluated on a different batch. Columns require sliced evaluation.
+fn consumer_properties(expr: &Arc<dyn PhysicalExpr>) ->
Option<ConsumerProperties> {
+ if let Some(value) = expr
+ .downcast_ref::<GetStructField>()
+ .and_then(GetStructField::literal_field)
+ {
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: true,
+ bytes: variable_bytes(&value.to_data()),
+ unbounded_output: false,
+ });
+ }
+ if let Some(literal) = expr.downcast_ref::<Literal>() {
+ let bytes = match literal.value() {
+ ScalarValue::Utf8(Some(value)) |
ScalarValue::LargeUtf8(Some(value)) => value.len(),
+ ScalarValue::Binary(Some(value)) |
ScalarValue::LargeBinary(Some(value)) => value.len(),
+ ScalarValue::Struct(value) => variable_bytes(&value.to_data()),
+ _ => 0,
+ };
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: matches!(literal.value(),
ScalarValue::Struct(_)),
Review Comment:
[P2] Carry the origin of resolved subquery literals into this check. An
ordinary `CAST(NULL AS STRUCT<k:BIGINT>)` also matches `ScalarValue::Struct`.
An enclosing array constructor consequently switches an existing native
projection to one-row execution even when the SQL contains no subquery.
Evidence: Write one Parquet partition from `SELECT id%2=0 AS
p,named_struct('k',id) AS payload FROM range(262144)`. Run `SELECT
array(IF(p,payload,CAST(NULL AS STRUCT<k:BIGINT>))) FROM t` with 8,192-row
batches. Both revisions used `CometProject` and produced 262,144 rows.
Consuming the projection’s columnar batches on Spark 4.1.3 gave five-run warm
medians of 53 ms on base and 1,634 ms on head.
##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,362 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.jdk.CollectionConverters._
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
BoundReference, CaseWhen, Cast, Coalesce, CreateArray, CreateMap,
CreateNamedStruct, DateFromUnixDate, DayOfWeek, ElementAt, EqualNullSafe,
EqualTo, Expression, ExprId, GetArrayItem, GetArrayStructFields, GetMapValue,
GetStructField, GreaterThan, GreaterThanOrEqual, Greatest, Hour, If, In, InSet,
IsNotNull, IsNull, KnownNotNull, KnownNullable, Least, LessThan,
LessThanOrEqual, Literal, Minute, NamedExpression, PreciseTimestampConversion,
Second, SortOrder, ToUnixTimestamp, TryEval, UnixDate, UnixTimestamp, WeekDay}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.comet.CometExec
+import org.apache.spark.sql.execution.{ExecSubqueryExpression, ExpandExec,
GenerateExec, ScalarSubquery, SparkPlan, UnionExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
QueryStageExec}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types._
+
+import com.google.protobuf.Message
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
import org.apache.comet.serde.QueryPlanSerde.{serializeDataType,
supportedDataType}
object CometScalarSubquery extends CometExpressionSerde[ScalarSubquery] {
+ private val structSubqueryInputs =
+ new TreeNodeTag[Set[ExprId]]("CometStructScalarSubqueryInputs")
+ private val structSubqueryContext =
+ new TreeNodeTag[Boolean]("CometStructScalarSubqueryContext")
+ private val currentStructSubqueryInputs = new
DynamicVariable[Set[ExprId]](Set.empty)
+ private val currentPlanHasStructSubquery = new
DynamicVariable[Boolean](false)
+
+ /**
+ * Trace struct scalar values before native conversion replaces their
producing projections.
+ * Attribute object identity is insufficient: aliases, nullable join outputs
and reused stages
+ * can create fresh attributes. Keep provenance by ExprId on the owning plan
instead.
+ *
+ * This is recomputed on every rule application, including the plans hidden
by AQE/reuse nodes.
+ * No provenance is inferred from a column's data type or carried in a
global ExprId registry.
+ */
+ private[comet] def tagPlanInputs(root: SparkPlan): Unit = {
+ val visited = new java.util.IdentityHashMap[SparkPlan, Set[ExprId]]()
+ def remember(plan: SparkPlan, sources: Set[ExprId]): Unit = {
+ val _ = visited.put(plan, sources)
+ }
+
+ def visit(plan: SparkPlan): Set[ExprId] = {
+ if (visited.containsKey(plan)) return visited.get(plan)
+ remember(plan, Set.empty)
+ plan.unsetTagValue(structSubqueryInputs)
+ plan.unsetTagValue(structSubqueryContext)
+
+ val original = plan match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val children = plan match {
+ case reused: ReusedExchangeExec => Seq(reused.child)
+ case stage: QueryStageExec => Seq(stage.plan)
+ case adaptive: AdaptiveSparkPlanExec => Seq(adaptive.executedPlan)
+ case comet: CometExec if comet.children.isEmpty =>
Seq(comet.originalPlan)
+ case other => other.children
+ }
+ val childSources = children.map(visit)
+ var sources = childSources.flatten.toSet
+ // A struct subquery can enable native conversion of both its operator
and ancestors.
+ // Keep that context even when a later projection drops the struct:
sibling/ancestor
+ // calendar consumers can still receive ordinary columns outside the
native range.
+ val hasStructContext =
+ children.exists(_.getTagValue(structSubqueryContext).contains(true)) ||
+ original.expressions.exists(hasStructSubquerySource(_, sources))
+ plan.setTagValue(structSubqueryContext, hasStructContext)
+ if (sources.isEmpty &&
+ !original.expressions.exists(_.containsPattern(PLAN_EXPRESSION))) {
+ return Set.empty
+ }
+
+ // Union and reused stages can expose different ExprIds for the same
output positions.
+ original match {
+ case _: UnionExec | _: ReusedExchangeExec | _: QueryStageExec |
+ _: AdaptiveSparkPlanExec =>
+ plan.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (children.zip(childSources).exists { case (child, ids) =>
+ child.output.lift(ordinal).exists(a => ids.contains(a.exprId))
+ }) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+
+ val named = original.expressions.flatMap(_.collect { case e:
NamedExpression => e })
+ var changed = true
+ while (changed) {
+ val previous = sources
+ named.foreach { expression =>
+ if (hasStructSubquerySource(expression, sources)) sources +=
expression.exprId
+ }
+ original match {
+ case aggregate: BaseAggregateExec =>
+
aggregate.aggregateExpressions.zip(aggregate.aggregateAttributes).foreach {
+ case (expression, output) =>
+ val function = expression.aggregateFunction
+ if (hasStructSubquerySource(expression, sources) ||
+ function.inputAggBufferAttributes.exists(a =>
sources.contains(a.exprId))) {
+ sources += output.exprId
+ sources ++= function.aggBufferAttributes.map(_.exprId)
+ sources ++= function.inputAggBufferAttributes.map(_.exprId)
+ }
+ }
+ case generate: GenerateExec if
hasStructSubquerySource(generate.generator, sources) =>
+ // Generators expose fresh attributes rather than NamedExpression
aliases.
+ sources ++= generate.generatorOutput.map(_.exprId)
+ case expand: ExpandExec =>
+ expand.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (expand.projections.exists(p =>
hasStructSubquerySource(p(ordinal), sources))) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+ changed = previous != sources
+ }
+
+ plan.setTagValue(structSubqueryInputs, sources)
+ val outputs =
plan.output.iterator.map(_.exprId).filter(sources.contains).toSet
+ remember(plan, outputs)
+ outputs
+ }
+
+ val _ = visit(root)
+ }
+
+ /** Keep the lineage local to this conversion, even when an attribute was
copied or rebound. */
+ private[comet] def withPlanInputs[T](plan: SparkPlan)(f: => T): T = {
+ val inputs = plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty)
+ currentStructSubqueryInputs.withValue(inputs) {
+ currentPlanHasStructSubquery.withValue(
+ plan.getTagValue(structSubqueryContext).getOrElse(false))(f)
+ }
+ }
+
+ private val calendarConsumerFallbackReason =
+ "Calendar operations on struct scalar subqueries require Spark because
their runtime values " +
+ "may exceed the native calendar range"
+
+ private val timezoneConsumerFallbackReason =
+ "Timezone-dependent scalar subquery operations require Spark for non-fixed
timezones"
+
+ private val dateUnixTimestampFallbackReason =
+ "Date scalar subqueries in unix_timestamp require Spark because their
runtime values " +
+ "may exceed the native calendar range"
+
+ private def containsCalendarType(dt: DataType): Boolean = dt match {
+ case DateType | TimestampType | TimestampNTZType => true
+ case s: StructType => s.fields.exists(f =>
containsCalendarType(f.dataType))
+ case ArrayType(elementType, _) => containsCalendarType(elementType)
+ case MapType(keyType, valueType, _) =>
+ containsCalendarType(keyType) || containsCalendarType(valueType)
+ case _ => false
+ }
+
+ private def containsVariableWidth(dt: DataType): Boolean = dt match {
+ case StringType | BinaryType => true
+ case s: StructType => s.fields.exists(f =>
containsVariableWidth(f.dataType))
+ case ArrayType(elementType, _) => containsVariableWidth(elementType)
+ case _ => false
+ }
+
+ private def hasVariableWidthStructSubquery(expr: Expression): Boolean =
+ expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
+ subquery.dataType.isInstanceOf[StructType] &&
containsVariableWidth(subquery.dataType)
+ case _ => false
+ }
+
+ /** Opaque JVM kernels cannot participate in the native consumer's bounded
evaluation. */
+ private[serde] def dispatchedConsumerFallbackReason(
+ expr: Expression,
+ proto: ExprOuterClass.Expr): Option[String] = {
+ def containsDispatch(message: Message): Boolean = message match {
+ case e: ExprOuterClass.Expr if e.hasJvmScalarUdf => true
+ case other =>
+ other.getAllFields.values().asScala.exists {
+ case child: Message => containsDispatch(child)
+ case children: java.util.List[_] =>
+ children.asScala.exists {
+ case child: Message => containsDispatch(child)
+ case _ => false
+ }
+ case _ => false
+ }
+ }
+ if (hasVariableWidthStructSubquery(expr) && containsDispatch(proto)) {
+ Some("JVM-dispatched consumers of variable-width struct scalar
subqueries require Spark")
+ } else {
+ None
+ }
+ }
+
+ private def isTimestamp(dt: DataType): Boolean =
+ dt == TimestampType || dt == TimestampNTZType
+
+ private def isEpochCast(from: DataType, to: DataType): Boolean =
+ (isTimestamp(from) && to == LongType) || (from == LongType &&
isTimestamp(to))
+
+ private def hasStructSubquerySource(expr: Expression, inputs: Set[ExprId]):
Boolean =
+ expr.references.exists(a => inputs.contains(a.exprId)) ||
+ (expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
subquery.dataType.isInstanceOf[StructType]
+ case _ => false
+ })
+
+ private def hasStructSubquery(expr: Expression): Boolean =
+ hasStructSubquerySource(expr, currentStructSubqueryInputs.value)
+
+ private def hasStructSubqueryContext(expr: Expression): Boolean =
+ currentPlanHasStructSubquery.value || hasStructSubquery(expr)
+
+ private def clockFallbackReason(
+ child: Expression,
+ timeZoneId: Option[String]): Option[String] = {
+ if (child.dataType == TimestampNTZType ||
CometTimeZone.isFixedOffset(timeZoneId)) {
+ None
+ } else {
+ Some(timezoneConsumerFallbackReason)
+ }
+ }
+
+ /**
+ * Admit only reviewed consumers of dates and timestamps on newly supported
struct-subquery
+ * paths. Unknown consumers stay on Spark by default, including expressions
whose result is not
+ * a calendar type. Transport, comparisons and epoch arithmetic do not
interpret a calendar.
+ *
+ * Check the whole expression before serialization or codegen dispatch,
including wrappers and
+ * whole-struct casts. A physical subquery may be reused, but it is still an
+ * [[ExecSubqueryExpression]]; its result must never be evaluated while
planning this check.
+ */
+ private[serde] def consumerFallbackReason(expr: Expression): Option[String]
= {
+ // Skip paths without struct subqueries. A newly admitted calendar
consumer can be a
+ // sibling or ancestor of a struct-producing operator without using its
result directly.
+ // Aliases still carry value provenance through the owning plan's ExprIds.
+ val derivedInput =
+ expr.references.exists(a =>
currentStructSubqueryInputs.value.contains(a.exprId))
+ if (!currentPlanHasStructSubquery.value && !derivedInput &&
+ (!expr.containsPattern(PLAN_EXPRESSION) ||
!ExecSubqueryExpression.hasSubquery(expr))) {
+ return None
+ }
+
+ // Native consumers may change batch boundaries to avoid repeating a large
scalar field.
+ // Stateful consumers must retain Spark's evaluation order and their
pre-existing fallback.
+ if (!expr.deterministic && hasVariableWidthStructSubquery(expr)) {
+ return Some(
+ "Nondeterministic consumers of variable-width struct scalar subqueries
require Spark")
+ }
+
+ expr match {
+ // Dynamic nested IN stops after a batch-wide match in native execution.
Keep row-dependent
+ // candidates on Spark rather than change ANSI/shared-expression
evaluation by slicing.
+ // Literal candidates use the separate static IN implementation.
Constant membership also
+ // needs sharing authorization: Spark may eagerly evaluate a shared,
failing candidate.
+ case in: In
+ if hasStructSubqueryContext(in) &&
+ (in.value.dataType.isInstanceOf[StructType] ||
+ in.value.dataType.isInstanceOf[ArrayType]) &&
+ containsVariableWidth(in.value.dataType) &&
Review Comment:
[P2] Apply the dynamic `IN` evaluation guard independently of field width.
Primitive and fixed-width struct values bypass it. A later candidate then runs
on a row that already matched and can abort the query. Constant fixed-width
membership can also skip a shared candidate that Spark evaluates. Preserve
Spark’s per-row and shared-expression rules, or retain fallback for these
shapes.
Evidence: With ANSI and subquery reuse enabled, AQE disabled, and
single-file Parquet tables `items(id,txt)=[(1,'bad'),(2,'2')]` and
`nums(k)=[0,1]`, run `SELECT id, id IN ((SELECT max(k) FROM nums), CAST(txt AS
INT)), (SELECT min(k) FROM nums) FROM items`. Spark 4.1.3 and the base returned
`(1,true,0),(2,true,0)`. Head `CometProject` raised `CAST_INVALID_INPUT` on row
1. A fixed-width struct membership query reproduced the same failure. In the
shared-candidate control, Spark and base raised the cast error with
subexpression elimination enabled, while head returned `(true,true)` for both
rows.
##########
native/spark-expr/src/datetime_funcs/unix_timestamp.rs:
##########
@@ -66,76 +66,81 @@ impl ScalarUDFImpl for SparkUnixTimestamp {
&self,
args: ScalarFunctionArgs,
) -> datafusion::common::Result<ColumnarValue> {
- let args: [ColumnarValue; 1] = args
+ let [arg]: [ColumnarValue; 1] = args
.args
.try_into()
.map_err(|_| internal_datafusion_err!("unix_timestamp expects
exactly one argument"))?;
+ let is_scalar = matches!(arg, ColumnarValue::Scalar(_));
+ let array = arg.into_array(1)?;
+ let result: ArrayRef = match array.data_type() {
+ DataType::Timestamp(Microsecond, None) => {
+ // TimestampNTZ: No timezone conversion needed - simply divide
microseconds
+ // by MICROS_PER_SECOND. TimestampNTZ stores local time
without timezone.
+ let timestamp_array =
+
array.as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
- match args {
- [ColumnarValue::Array(array)] => match array.data_type() {
- DataType::Timestamp(Microsecond, None) => {
- // TimestampNTZ: No timezone conversion needed - simply
divide microseconds
- // by MICROS_PER_SECOND. TimestampNTZ stores local time
without timezone.
- let timestamp_array =
-
array.as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
+ let result: PrimitiveArray<Int64Type> =
+ timestamp_array.unary(|micros| micros / MICROS_PER_SECOND);
- let result: PrimitiveArray<Int64Type> =
- timestamp_array.unary(|micros| micros /
MICROS_PER_SECOND);
+ Arc::new(result)
+ }
+ DataType::Timestamp(_, _) => {
+ let is_utc = self.timezone == "UTC";
+ let array = if is_utc
+ && matches!(array.data_type(),
DataType::Timestamp(Microsecond, Some(tz)) if tz.as_ref() == "UTC")
+ {
+ array
+ } else {
+ array_with_timezone(
+ array,
+ self.timezone.clone(),
+ Some(&DataType::Timestamp(Microsecond,
Some("UTC".into()))),
+ )?
+ };
- Ok(ColumnarValue::Array(Arc::new(result)))
- }
- DataType::Timestamp(_, _) => {
- let is_utc = self.timezone == "UTC";
- let array = if is_utc
- && matches!(array.data_type(),
DataType::Timestamp(Microsecond, Some(tz)) if tz.as_ref() == "UTC")
- {
- array
- } else {
- array_with_timezone(
- array,
- self.timezone.clone(),
- Some(&DataType::Timestamp(Microsecond,
Some("UTC".into()))),
- )?
- };
-
- let timestamp_array =
-
array.as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
-
- let result: PrimitiveArray<Int64Type> =
- timestamp_array.unary(|micros| micros /
MICROS_PER_SECOND);
-
- Ok(ColumnarValue::Array(Arc::new(result)))
- }
- DataType::Date32 => {
- let timestamp_array = cast(&array,
&DataType::Timestamp(Microsecond, None))?;
-
- let is_utc = self.timezone == "UTC";
- let array = if is_utc {
- timestamp_array
- } else {
- array_with_timezone(
- timestamp_array,
- self.timezone.clone(),
- Some(&DataType::Timestamp(Microsecond,
Some("UTC".into()))),
- )?
- };
-
- let timestamp_array =
-
array.as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
-
- let result: PrimitiveArray<Int64Type> =
- timestamp_array.unary(|micros| micros /
MICROS_PER_SECOND);
-
- Ok(ColumnarValue::Array(Arc::new(result)))
- }
- _ => Err(DataFusionError::Execution(format!(
+ let timestamp_array =
+
array.as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
+
+ let result: PrimitiveArray<Int64Type> =
+ timestamp_array.unary(|micros| micros / MICROS_PER_SECOND);
+
+ Arc::new(result)
+ }
+ DataType::Date32 => {
+ let timestamp_array = cast(&array,
&DataType::Timestamp(Microsecond, None))?;
Review Comment:
[P2] Preserve Spark’s checked, zoned start-of-day conversion for scalar
`Date32` inputs. An ordinary conditional can supply a scalar date without a
subquery. Arrow’s date-to-microsecond cast turns overflow into NULL, and the
regional conversion can select the wrong offset for a representable summer
date. Use Spark-equivalent date conversion or keep affected scalar dates on
Spark.
Evidence: With integer IDs 1 and 2 in one Parquet batch and the session in
UTC, run `SELECT
unix_timestamp(IF(id>0,date_from_unix_date(2147483647),date_from_unix_date(id)))
FROM items`. Spark 4.1.3 raised `ArithmeticException: long overflow`. Head
returned `[NULL,NULL]`. With `DATE '2200-07-01'` as the true branch and
`America/Los_Angeles` as the zone, Spark returned `7273782000` and head
returned `7273785600` for each row. The base explicitly rejected both scalar
inputs.
##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,362 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.jdk.CollectionConverters._
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
BoundReference, CaseWhen, Cast, Coalesce, CreateArray, CreateMap,
CreateNamedStruct, DateFromUnixDate, DayOfWeek, ElementAt, EqualNullSafe,
EqualTo, Expression, ExprId, GetArrayItem, GetArrayStructFields, GetMapValue,
GetStructField, GreaterThan, GreaterThanOrEqual, Greatest, Hour, If, In, InSet,
IsNotNull, IsNull, KnownNotNull, KnownNullable, Least, LessThan,
LessThanOrEqual, Literal, Minute, NamedExpression, PreciseTimestampConversion,
Second, SortOrder, ToUnixTimestamp, TryEval, UnixDate, UnixTimestamp, WeekDay}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.comet.CometExec
+import org.apache.spark.sql.execution.{ExecSubqueryExpression, ExpandExec,
GenerateExec, ScalarSubquery, SparkPlan, UnionExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
QueryStageExec}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types._
+
+import com.google.protobuf.Message
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
import org.apache.comet.serde.QueryPlanSerde.{serializeDataType,
supportedDataType}
object CometScalarSubquery extends CometExpressionSerde[ScalarSubquery] {
+ private val structSubqueryInputs =
+ new TreeNodeTag[Set[ExprId]]("CometStructScalarSubqueryInputs")
+ private val structSubqueryContext =
+ new TreeNodeTag[Boolean]("CometStructScalarSubqueryContext")
+ private val currentStructSubqueryInputs = new
DynamicVariable[Set[ExprId]](Set.empty)
+ private val currentPlanHasStructSubquery = new
DynamicVariable[Boolean](false)
+
+ /**
+ * Trace struct scalar values before native conversion replaces their
producing projections.
+ * Attribute object identity is insufficient: aliases, nullable join outputs
and reused stages
+ * can create fresh attributes. Keep provenance by ExprId on the owning plan
instead.
+ *
+ * This is recomputed on every rule application, including the plans hidden
by AQE/reuse nodes.
+ * No provenance is inferred from a column's data type or carried in a
global ExprId registry.
+ */
+ private[comet] def tagPlanInputs(root: SparkPlan): Unit = {
+ val visited = new java.util.IdentityHashMap[SparkPlan, Set[ExprId]]()
+ def remember(plan: SparkPlan, sources: Set[ExprId]): Unit = {
+ val _ = visited.put(plan, sources)
+ }
+
+ def visit(plan: SparkPlan): Set[ExprId] = {
+ if (visited.containsKey(plan)) return visited.get(plan)
+ remember(plan, Set.empty)
+ plan.unsetTagValue(structSubqueryInputs)
+ plan.unsetTagValue(structSubqueryContext)
+
+ val original = plan match {
+ case comet: CometExec => comet.originalPlan
+ case other => other
+ }
+ val children = plan match {
+ case reused: ReusedExchangeExec => Seq(reused.child)
+ case stage: QueryStageExec => Seq(stage.plan)
+ case adaptive: AdaptiveSparkPlanExec => Seq(adaptive.executedPlan)
+ case comet: CometExec if comet.children.isEmpty =>
Seq(comet.originalPlan)
+ case other => other.children
+ }
+ val childSources = children.map(visit)
+ var sources = childSources.flatten.toSet
+ // A struct subquery can enable native conversion of both its operator
and ancestors.
+ // Keep that context even when a later projection drops the struct:
sibling/ancestor
+ // calendar consumers can still receive ordinary columns outside the
native range.
+ val hasStructContext =
+ children.exists(_.getTagValue(structSubqueryContext).contains(true)) ||
+ original.expressions.exists(hasStructSubquerySource(_, sources))
+ plan.setTagValue(structSubqueryContext, hasStructContext)
+ if (sources.isEmpty &&
+ !original.expressions.exists(_.containsPattern(PLAN_EXPRESSION))) {
+ return Set.empty
+ }
+
+ // Union and reused stages can expose different ExprIds for the same
output positions.
+ original match {
+ case _: UnionExec | _: ReusedExchangeExec | _: QueryStageExec |
+ _: AdaptiveSparkPlanExec =>
+ plan.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (children.zip(childSources).exists { case (child, ids) =>
+ child.output.lift(ordinal).exists(a => ids.contains(a.exprId))
+ }) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+
+ val named = original.expressions.flatMap(_.collect { case e:
NamedExpression => e })
+ var changed = true
+ while (changed) {
+ val previous = sources
+ named.foreach { expression =>
+ if (hasStructSubquerySource(expression, sources)) sources +=
expression.exprId
+ }
+ original match {
+ case aggregate: BaseAggregateExec =>
+
aggregate.aggregateExpressions.zip(aggregate.aggregateAttributes).foreach {
+ case (expression, output) =>
+ val function = expression.aggregateFunction
+ if (hasStructSubquerySource(expression, sources) ||
+ function.inputAggBufferAttributes.exists(a =>
sources.contains(a.exprId))) {
+ sources += output.exprId
+ sources ++= function.aggBufferAttributes.map(_.exprId)
+ sources ++= function.inputAggBufferAttributes.map(_.exprId)
+ }
+ }
+ case generate: GenerateExec if
hasStructSubquerySource(generate.generator, sources) =>
+ // Generators expose fresh attributes rather than NamedExpression
aliases.
+ sources ++= generate.generatorOutput.map(_.exprId)
+ case expand: ExpandExec =>
+ expand.output.zipWithIndex.foreach { case (output, ordinal) =>
+ if (expand.projections.exists(p =>
hasStructSubquerySource(p(ordinal), sources))) {
+ sources += output.exprId
+ }
+ }
+ case _ =>
+ }
+ changed = previous != sources
+ }
+
+ plan.setTagValue(structSubqueryInputs, sources)
+ val outputs =
plan.output.iterator.map(_.exprId).filter(sources.contains).toSet
+ remember(plan, outputs)
+ outputs
+ }
+
+ val _ = visit(root)
+ }
+
+ /** Keep the lineage local to this conversion, even when an attribute was
copied or rebound. */
+ private[comet] def withPlanInputs[T](plan: SparkPlan)(f: => T): T = {
+ val inputs = plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty)
+ currentStructSubqueryInputs.withValue(inputs) {
+ currentPlanHasStructSubquery.withValue(
+ plan.getTagValue(structSubqueryContext).getOrElse(false))(f)
+ }
+ }
+
+ private val calendarConsumerFallbackReason =
+ "Calendar operations on struct scalar subqueries require Spark because
their runtime values " +
+ "may exceed the native calendar range"
+
+ private val timezoneConsumerFallbackReason =
+ "Timezone-dependent scalar subquery operations require Spark for non-fixed
timezones"
+
+ private val dateUnixTimestampFallbackReason =
+ "Date scalar subqueries in unix_timestamp require Spark because their
runtime values " +
+ "may exceed the native calendar range"
+
+ private def containsCalendarType(dt: DataType): Boolean = dt match {
+ case DateType | TimestampType | TimestampNTZType => true
+ case s: StructType => s.fields.exists(f =>
containsCalendarType(f.dataType))
+ case ArrayType(elementType, _) => containsCalendarType(elementType)
+ case MapType(keyType, valueType, _) =>
+ containsCalendarType(keyType) || containsCalendarType(valueType)
+ case _ => false
+ }
+
+ private def containsVariableWidth(dt: DataType): Boolean = dt match {
+ case StringType | BinaryType => true
+ case s: StructType => s.fields.exists(f =>
containsVariableWidth(f.dataType))
+ case ArrayType(elementType, _) => containsVariableWidth(elementType)
+ case _ => false
+ }
+
+ private def hasVariableWidthStructSubquery(expr: Expression): Boolean =
+ expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+ case subquery: ExecSubqueryExpression =>
+ subquery.dataType.isInstanceOf[StructType] &&
containsVariableWidth(subquery.dataType)
Review Comment:
[P2] Include expanding intermediate values in the fallback decision for
stateful or JVM-dispatched consumers. An integer subquery field can control
`repeat` and produce a wide string. Checking only the materialized field type
admits the tree, while the opaque or stateful predicate prevents bounded
evaluation of its parent. Retain fallback when the intermediate cannot be
consumed safely.
Evidence: Use Parquet `src(n)=[262144]` and IDs 0–8191 in one outer batch,
with AQE off and reuse on. Run `SELECT sum(y),min(m) FROM (SELECT
octet_length(IF(monotonically_increasing_id()>=0,repeat('x',2*(SELECT max(n)
FROM src)),'z')) y,(SELECT min(n) FROM src) m FROM outer_rows) q`. Spark and
base returned `(4294967296,262144)`. Head failed with `Scalar subquery consumer
result exceeds Arrow's 32-bit offset limit`. Replacing the predicate with
`upper(CAST(id AS STRING)) <> 'missing'` reproduced the same failure. Both
final outputs fit.
##########
native/spark-expr/src/scalar_subquery_consumer.rs:
##########
@@ -0,0 +1,358 @@
+// 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.
+
+use crate::array_funcs::NestedPredicate;
+use crate::{
+ ArrayInsert, AtLeastNNonNulls, CaseWhenExpr, Cast, CheckedBinaryExpr,
CreateNamedStruct,
+ FromJson, GetArrayStructFields, GetStructField, IfExpr, ListExtract,
NegativeExpr,
+ NormalizeNestedFloats, NullShortCircuit, RLike, SparkArraysZipFunc,
SparkComparison, Subquery,
+ ToCsv, ToJson,
+};
+use arrow::array::{new_empty_array, Array, ArrayData, ArrayRef};
+use arrow::compute::concat;
+use arrow::datatypes::{DataType, FieldRef, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::{exec_err, internal_err, Result, ScalarValue};
+use datafusion::logical_expr::{ColumnarValue, Volatility};
+use datafusion::physical_expr::expressions::{
+ BinaryExpr, CastExpr, Column, InListExpr, IsNotNullExpr, IsNullExpr,
LikeExpr, Literal, NotExpr,
+};
+use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr};
+use std::fmt::{Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+/// Evaluates scalar-struct consumers before repeating large variable-width
fields.
+///
+/// GetStructField retains its batch-sized array contract. Constant consumers
run on one row;
+/// pure row-dependent consumers run on bounded slices before their results
are concatenated.
+/// Nothing is evaluated during planning, preserving conditional and ANSI NULL
short-circuiting.
+#[derive(Debug, Eq)]
+pub struct ScalarSubqueryConsumer {
+ inner: Arc<dyn PhysicalExpr>,
+ constant: bool,
+ max_rows: usize,
+}
+
+// Limit repeated scalar input payload, not the size of the user's final
output. A single scalar
+// can exceed this budget; in that case consume one row at a time.
+const SCALAR_INPUT_BYTES: usize = 1024 * 1024;
+
+impl ScalarSubqueryConsumer {
+ pub fn wrap(inner: Arc<dyn PhysicalExpr>) -> Arc<dyn PhysicalExpr> {
+ // Keep literals and direct field chains visible so a narrow field can
be selected
+ // before broadcasting. A field read over a computed struct is itself
a consumer.
+ if inner.is::<Literal>()
+ || inner.is::<Self>()
+ || inner
+ .downcast_ref::<GetStructField>()
+ .is_some_and(|field| field.literal_field().is_some())
Review Comment:
[P2] Bound direct scalar-field arguments at physical operator boundaries.
This exemption assumes an enclosing scalar expression will limit expansion. A
`COUNT` accumulator or native hash partitioner instead requests the field for
the full batch. Both can panic while producing only counts or partition IDs.
Consume the scalar’s validity or hash directly, bound the operator input, or
retain fallback.
Evidence: Use Parquet `wide(s)` containing two 262,144-byte strings and one
8,192-row batch `outer_ids(k)` with alternating keys 0 and 1. With AQE off and
reuse on, `SELECT k,count((SELECT max(s) FROM wide)),count((SELECT min(s) FROM
wide)) FROM outer_ids GROUP BY k` returned `(0,4096,4096),(1,4096,4096)` on
Spark and base. Head `CometHashAggregate` panicked with `offset overflow`.
Using the two subqueries directly as `DISTRIBUTE BY` keys reproduced the panic
in native shuffle. One repeated field needs 2,147,483,648 value bytes, which
Arrow rejects before allocation. The earlier `octet_length` case passes on this
data.
##########
native/spark-expr/src/scalar_subquery_consumer.rs:
##########
@@ -0,0 +1,358 @@
+// 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.
+
+use crate::array_funcs::NestedPredicate;
+use crate::{
+ ArrayInsert, AtLeastNNonNulls, CaseWhenExpr, Cast, CheckedBinaryExpr,
CreateNamedStruct,
+ FromJson, GetArrayStructFields, GetStructField, IfExpr, ListExtract,
NegativeExpr,
+ NormalizeNestedFloats, NullShortCircuit, RLike, SparkArraysZipFunc,
SparkComparison, Subquery,
+ ToCsv, ToJson,
+};
+use arrow::array::{new_empty_array, Array, ArrayData, ArrayRef};
+use arrow::compute::concat;
+use arrow::datatypes::{DataType, FieldRef, Schema};
+use arrow::record_batch::RecordBatch;
+use datafusion::common::{exec_err, internal_err, Result, ScalarValue};
+use datafusion::logical_expr::{ColumnarValue, Volatility};
+use datafusion::physical_expr::expressions::{
+ BinaryExpr, CastExpr, Column, InListExpr, IsNotNullExpr, IsNullExpr,
LikeExpr, Literal, NotExpr,
+};
+use datafusion::physical_expr::{PhysicalExpr, ScalarFunctionExpr};
+use std::fmt::{Display, Formatter};
+use std::hash::{Hash, Hasher};
+use std::sync::Arc;
+
+/// Evaluates scalar-struct consumers before repeating large variable-width
fields.
+///
+/// GetStructField retains its batch-sized array contract. Constant consumers
run on one row;
+/// pure row-dependent consumers run on bounded slices before their results
are concatenated.
+/// Nothing is evaluated during planning, preserving conditional and ANSI NULL
short-circuiting.
+#[derive(Debug, Eq)]
+pub struct ScalarSubqueryConsumer {
+ inner: Arc<dyn PhysicalExpr>,
+ constant: bool,
+ max_rows: usize,
+}
+
+// Limit repeated scalar input payload, not the size of the user's final
output. A single scalar
+// can exceed this budget; in that case consume one row at a time.
+const SCALAR_INPUT_BYTES: usize = 1024 * 1024;
+
+impl ScalarSubqueryConsumer {
+ pub fn wrap(inner: Arc<dyn PhysicalExpr>) -> Arc<dyn PhysicalExpr> {
+ // Keep literals and direct field chains visible so a narrow field can
be selected
+ // before broadcasting. A field read over a computed struct is itself
a consumer.
+ if inner.is::<Literal>()
+ || inner.is::<Self>()
+ || inner
+ .downcast_ref::<GetStructField>()
+ .is_some_and(|field| field.literal_field().is_some())
+ {
+ return inner;
+ }
+ if let Some(properties) = consumer_properties(&inner) {
+ if properties.has_scalar_struct
+ && (properties.constant || properties.bytes != 0 ||
properties.unbounded_output)
+ {
+ return Arc::new(Self {
+ inner,
+ constant: properties.constant,
+ max_rows: if properties.unbounded_output {
+ 1
+ } else {
+ (SCALAR_INPUT_BYTES / properties.bytes.max(1)).max(1)
+ },
+ });
+ }
+ }
+ inner
+ }
+}
+
+struct ConsumerProperties {
+ constant: bool,
+ has_scalar_struct: bool,
+ bytes: usize,
+ unbounded_output: bool,
+}
+
+/// Explicitly recognize pure row-local trees. Unknown nodes and volatile
functions cannot safely
+/// be evaluated on a different batch. Columns require sliced evaluation.
+fn consumer_properties(expr: &Arc<dyn PhysicalExpr>) ->
Option<ConsumerProperties> {
+ if let Some(value) = expr
+ .downcast_ref::<GetStructField>()
+ .and_then(GetStructField::literal_field)
+ {
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: true,
+ bytes: variable_bytes(&value.to_data()),
+ unbounded_output: false,
+ });
+ }
+ if let Some(literal) = expr.downcast_ref::<Literal>() {
+ let bytes = match literal.value() {
+ ScalarValue::Utf8(Some(value)) |
ScalarValue::LargeUtf8(Some(value)) => value.len(),
+ ScalarValue::Binary(Some(value)) |
ScalarValue::LargeBinary(Some(value)) => value.len(),
+ ScalarValue::Struct(value) => variable_bytes(&value.to_data()),
+ _ => 0,
+ };
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: matches!(literal.value(),
ScalarValue::Struct(_)),
+ bytes,
+ unbounded_output: false,
+ });
+ }
+ // Spark materializes and registers this execution-context value before
native execution.
+ // Lookup still happens at runtime; it is independent of the current input
row or batch.
+ if expr.is::<Subquery>() {
+ return Some(ConsumerProperties {
+ constant: true,
+ has_scalar_struct: false,
+ bytes: 0,
+ unbounded_output: false,
+ });
+ }
+ if expr.is::<Column>() {
+ return Some(ConsumerProperties {
+ constant: false,
+ has_scalar_struct: false,
+ bytes: 0,
+ unbounded_output: false,
+ });
+ }
+ if let Some(expr) = expr.downcast_ref::<ScalarSubqueryConsumer>() {
+ return consumer_properties(&expr.inner);
+ }
+ if let Some(expr) = expr.downcast_ref::<NullShortCircuit>() {
+ return consumer_properties(expr.inner());
+ }
+ if let Some(fun) = expr.downcast_ref::<ScalarFunctionExpr>() {
+ if fun.fun().signature().volatility != Volatility::Immutable {
+ return None;
+ }
+ } else if !(expr.is::<AtLeastNNonNulls>()
+ || expr.is::<ArrayInsert>()
+ || expr.is::<GetArrayStructFields>()
+ || expr.is::<SparkArraysZipFunc>()
+ || expr.is::<NestedPredicate>()
+ || expr.is::<NormalizeNestedFloats>()
+ || expr.is::<FromJson>()
+ || expr.is::<ToCsv>()
+ || expr.is::<GetStructField>()
+ || expr.is::<CastExpr>()
+ || expr.is::<Cast>()
+ || expr.is::<BinaryExpr>()
+ || expr.is::<CheckedBinaryExpr>()
+ || expr.is::<NegativeExpr>()
+ || expr.is::<SparkComparison>()
+ || expr.is::<IsNullExpr>()
+ || expr.is::<IsNotNullExpr>()
+ || expr.is::<NotExpr>()
+ || expr.is::<InListExpr>()
+ || expr.is::<LikeExpr>()
+ || expr.is::<RLike>()
+ || expr.is::<IfExpr>()
+ || expr.is::<CaseWhenExpr>()
+ || expr.is::<CreateNamedStruct>()
+ || expr.is::<ListExtract>()
+ || expr.is::<ToJson>())
+ {
+ return None;
+ }
+ let mut properties = ConsumerProperties {
+ constant: true,
+ has_scalar_struct: false,
+ bytes: 0,
+ // Input payload does not bound outputs such as repeat, padding, JSON
or growing lists.
+ // Propagate this even from constant children: a row-dependent IF can
select them for
+ // a whole batch before its parent reduces the result.
+ unbounded_output: expr
+ .downcast_ref::<ScalarFunctionExpr>()
+ .map(ScalarFunctionExpr::return_type)
+ .or_else(|| expr.downcast_ref::<Cast>().map(|cast|
&cast.data_type))
+ .or_else(||
expr.downcast_ref::<CastExpr>().map(CastExpr::cast_type))
+ .is_some_and(|dt| {
+ matches!(
+ dt,
+ DataType::Utf8
+ | DataType::LargeUtf8
+ | DataType::Utf8View
+ | DataType::Binary
+ | DataType::LargeBinary
+ | DataType::BinaryView
+ ) || dt.is_nested()
Review Comment:
[P2] Keep fixed-cardinality array constructors vectorized when their
elements have bounded width. `dt.is_nested()` marks a three-`BIGINT` array as
unbounded. With a genuine merged subquery, that decision propagates to the
enclosing hash and reduces its batch size to one. Derive a bound from the
constructor’s arity and element types.
Evidence: Use one Parquet partition `t` with `payload.k` holding `BIGINT`
IDs 0–262143 and Parquet `v(id)=[0,1]`. Run `SELECT
sum(CAST(hash(array(payload.k,CAST((SELECT max(id) FROM v) AS
BIGINT),CAST((SELECT min(id) FROM v) AS BIGINT))) AS BIGINT)) FROM t` with AQE
off and batch size 8,192. Spark merged the two subqueries. Base and head
returned `1095857758320`. Five post-warm-up end-to-end actions on one worker
had medians of 242 ms on base and 2,837 ms on head.
--
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]