comphead commented on code in PR #5867:
URL: https://github.com/apache/datafusion-comet/pull/5867#discussion_r4329264331


##########
spark/src/main/scala/org/apache/comet/serde/arrays.scala:
##########
@@ -60,7 +60,120 @@ object CometArrayRemove
   }
 }
 
-object CometArrayAppend extends CometExpressionSerde[ArrayAppend] with 
ArraysBase {
+/**
+ * Shared gate for serdes whose native NULL guard (`CASE WHEN child IS NOT 
NULL`) serializes the
+ * child twice: a stateful child drifts between the two copies, so it is 
declined and runs through
+ * the JVM codegen dispatcher, where Spark evaluates it once. Nullability is 
not consulted: a
+ * non-nullable stateful child only stays in step because DataFusion skips the 
filter when the
+ * guard matches every row, which is not a contract to lean on.
+ */
+private[serde] object NullGuardSupport {
+
+  val nondeterministicReason: String =
+    "Comet has no native path for a nondeterministic operand such as `rand()` 
or " +
+      "`monotonically_increasing_id()`, because the native `NULL` guard would 
evaluate it twice."
+
+  /** `Unsupported` when any of `children` is nondeterministic, otherwise 
`None`. */
+  def nondeterministicChild(children: Seq[Expression]): Option[SupportLevel] =
+    children
+      .find(child => !child.deterministic)
+      .map(_ => Unsupported(Some(nondeterministicReason)))
+}
+
+/**
+ * Keeps a result holding a calendar interval, at any nesting depth, off the 
JVM codegen
+ * dispatcher. The dispatcher writes the interval's microseconds to Arrow as 
nanoseconds, which
+ * overflows a long beyond about 292 years, a range Spark represents
+ * ([#5279](https://github.com/apache/datafusion-comet/issues/5279)). Such an 
expression falls
+ * back to Spark instead.
+ */
+private[serde] object CalendarIntervalOutput {
+
+  private val reason: String =
+    "the result holds a calendar interval, whose microseconds overflow the 
dispatcher's Arrow " +
+      "nanoseconds beyond about 292 years"
+
+  def declineReason(expr: Expression): Option[String] =
+    if (SupportLevel.containsType(expr.dataType, 
classOf[CalendarIntervalType])) Some(reason)
+    else None
+}
+
+/**
+ * Keeps an expression off the JVM codegen dispatcher when one of its nodes 
could skip an argument
+ * holding a subexpression that the operator's other expressions share (see
+ * `QueryPlanSerde.skipsSharedSubexpression`). Spark evaluates such a 
subexpression for every row,
+ * so the expression falls back to Spark instead.
+ */
+private[serde] object SharedSubexpressionArgument {
+
+  private val reason: String =
+    "an argument this expression can skip holds a subexpression that the 
operator's other " +
+      "expressions share, which Spark evaluates for every row before this 
expression's NULL check"
+
+  def declineReason(expr: Expression): Option[String] =
+    if (skipsSharedSubexpression(expr)) Some(reason) else None
+}
+
+/**
+ * Whether evaluating `expr` at a different point than Spark does, or not at 
all, is unobservable.
+ *
+ * A literal or column read cannot throw, carry state or have a side effect, 
so neither its
+ * evaluation order nor a skipped evaluation can be observed; anything else 
may raise or drift
+ * where Spark does not. `foldable` is not usable here: ConstantFolding leaves 
a throwing foldable
+ * expression unfolded in a conditional branch.
+ */
+private[serde] object EvaluationOrder {
+
+  def isUnobservable(expr: Expression): Boolean = expr match {
+    case _: Literal | _: Attribute | _: BoundReference => true
+    case _ => false
+  }
+}
+
+object CometArrayAppend
+    extends CometExpressionSerde[ArrayAppend]
+    with ArraysBase
+    with CodegenDispatchFallback {
+
+  override def dispatchDeclineReason(expr: Expression): Option[String] =
+    CalendarIntervalOutput
+      .declineReason(expr)
+      .orElse(SharedSubexpressionArgument.declineReason(expr))

Review Comment:
   Five serdes carry this same override: `CometArrayAppend` here, 
`CometElementAt` (line 950), `CometArraysZip` (line 1179), and 
`CometMapExtract` and `CometMapFromArrays` in `maps.scala` (lines 125 and 192). 
`CometSize` (line 1088) has it without the `CalendarIntervalOutput` half, which 
cannot fire there because `Size` returns an int. Could one shared definition 
hold the two gates, for example a small trait next to 
`CodegenDispatchFallback`, so a new serde gets them by default instead of by 
copy? `getUnsupportedReasons` has the same repetition, where four serdes return 
`Seq(NullGuardSupport.nondeterministicReason)`.



##########
spark/src/test/resources/sql-tests/expressions/array/size_shared_subexpression.sql:
##########
@@ -0,0 +1,65 @@
+-- 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.
+
+-- Spark's projection evaluates a subexpression that several of its 
expressions share for every
+-- row, before the expressions that use it. A `size` over a lookup whose key 
holds a
+-- nondeterministic `rand(7)` goes through the dispatcher, whose decline check 
walks the whole
+-- tree and finds the lookup skipping the shared `1.0D / x` on the row whose 
map is NULL.
+
+-- Config: spark.sql.ansi.enabled=true
+-- Config: spark.sql.subexpressionElimination.enabled=true
+
+statement
+CREATE TABLE test_size_shared(m1 map<double, array<int>>, m2 map<double, 
array<int>>, x double)
+USING parquet
+
+statement
+INSERT INTO test_size_shared VALUES
+  (CAST(NULL AS MAP<DOUBLE, ARRAY<INT>>), CAST(NULL AS MAP<DOUBLE, 
ARRAY<INT>>), 0.0D),
+  (map(0.5D, array(1, 2)), map(0.5D, array(3)), 2.0D)
+
+-- Without a nondeterministic key `size` runs natively, so this query 
exercises the decline of the
+-- dispatched `element_at`.
+query expect_error(DIVIDE_BY_ZERO)
+SELECT size(element_at(m1, 1.0D / x)), size(element_at(m2, 1.0D / x)) FROM 
test_size_shared
+
+-- The `+ rand(7) * 0.0D` leaves the key's value unchanged.
+query expect_error(DIVIDE_BY_ZERO)
+SELECT size(element_at(m1, 1.0D / x + rand(7) * 0.0D)),
+  size(element_at(m2, 1.0D / x + rand(7) * 0.0D))
+FROM test_size_shared
+
+-- A single call shares nothing, so Spark skips the divide on the NULL map too.
+query expect_dispatch(size)
+SELECT size(element_at(m1, 1.0D / x + rand(7) * 0.0D)) FROM test_size_shared
+
+-- On rows where the divide cannot fail, the shared `1.0D / x` still sends the 
calls back to
+-- Spark.
+statement
+CREATE TABLE test_size_shared_nonzero(
+  m1 map<double, array<int>>, m2 map<double, array<int>>, x double)
+USING parquet
+
+statement
+INSERT INTO test_size_shared_nonzero VALUES
+  (CAST(NULL AS MAP<DOUBLE, ARRAY<INT>>), CAST(NULL AS MAP<DOUBLE, 
ARRAY<INT>>), 4.0D),
+  (map(0.5D, array(1, 2)), map(0.5D, array(3)), 2.0D)
+
+query expect_fallback(holds a subexpression that the operator's other 
expressions share)
+SELECT size(element_at(m1, 1.0D / x + rand(7) * 0.0D)),
+  size(element_at(m2, 1.0D / x + rand(7) * 0.0D))
+FROM test_size_shared_nonzero

Review Comment:
   The `_nonzero` copy of the table exists only so the `expect_fallback` query 
can run on rows where the divide cannot fail. 
`array_append_null_array_item.sql` (line 109) gets the same effect with `WHERE 
_1 = 2` on the existing table. Could this and the matching `_nonzero` tables in 
`array_append_shared_subexpression.sql`, `arrays_zip_shared_subexpression.sql`, 
`map_from_arrays_shared_subexpression.sql`, 
`map_lookup_shared_subexpression.sql` and 
`map_lookup_shared_subexpression_operators.sql` drop the second `CREATE` and 
`INSERT` by filtering the original table, or by using `1.0D / (x + 1.0D)`? I 
did not check that a `WHERE` keeps the sharing in the `DISTRIBUTE BY`, sort and 
`TakeOrderedAndProject` cases of the operators file, so the `x + 1.0D` form may 
be the safer one there.



##########
spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala:
##########
@@ -388,30 +388,33 @@ class CometMapExpressionSuite extends CometTestBase {
 
   // Finding E: a map nested inside a map value is handed to the JVM 
dispatcher whole, so its double
   // keys never revisit `CometLiteral`. The guard has to live on the lookup 
instead. The outer key
-  // type is INT, so inspecting only the outermost map would admit the query; 
the fallback comes
-  // from the inner `element_at`, whose child is `MapType(DoubleType, 
IntegerType)`. Constant folding
-  // is on here, which is the only configuration where the inner map becomes 
such a literal, so this
-  // regression cannot be expressed in a SQL fixture (the harness disables 
folding). The direct
-  // single-level double-key lookups live in `element_at_map.sql` / 
`get_map_value.sql`.
-  test("nested map lookup with floating-point keys falls back") {
+  // type is INT, so inspecting only the outermost map would admit the query; 
the dispatch comes
+  // from the `element_at` whose child is `MapType(DoubleType, IntegerType)`, 
which takes the whole
+  // nested lookup with it. Constant folding is on here, which is the only 
configuration where the
+  // inner map becomes such a literal, so this regression cannot be expressed 
in a SQL fixture (the
+  // harness disables folding). The direct single-level double-key lookups 
live in

Review Comment:
   This comment says the regression cannot be expressed in a SQL fixture 
because the harness disables folding. `-- ConstantFolding: enabled` is 
supported on `main` (`folded_array_map_literals.sql`, `cast_literal_ansi.sql`), 
and `CometArrayExpressionSuite` already says so. Since this PR rewrites the "is 
dispatched" tests in this suite anyway, could they move to a fixture with that 
header and `expect_dispatch(element_at)`? If not, the sentence needs 
correcting. I did not check that the nested multi-row forms reproduce in the 
harness.



##########
spark/src/test/resources/sql-tests/expressions/array/array_append_ansi_null_array.sql:
##########
@@ -0,0 +1,84 @@
+-- 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.
+
+-- `CometArrayAppend` reproduces Spark's NULL propagation with a `CASE WHEN 
array IS NOT NULL`
+-- guard, and the item sits inside the guard's THEN branch. DataFusion 
evaluates that branch only
+-- on the rows the guard selects, while Spark's codegen evaluates the item on 
every row. Under
+-- ANSI mode an item that raises on a row whose array is NULL therefore raises 
in Spark and stays
+-- silent on the native path. The serde reports a nullable array with an item 
that is not a
+-- literal or column as incompatible, so by default the expression runs 
through the JVM codegen
+-- dispatcher, which raises like Spark. A literal or column item cannot raise, 
and a non-nullable
+-- array evaluates the item on every row on both paths, so both stay native.
+-- `array_append_null_array_item.sql` covers the same rules with ANSI off.
+--
+-- Spark 4.0 rewrites `array_append` to `array_insert(-1)` before serde, so 
`CometArrayAppend` is
+-- only reachable on Spark 3.x.
+
+-- MaxSparkVersion: 3.5
+
+-- Config: spark.sql.ansi.enabled=true
+
+statement
+CREATE TABLE test_array_append_ansi(_1 int, arr array<int>) USING parquet
+
+statement
+INSERT INTO test_array_append_ansi
+SELECT id, IF(id = 1, NULL, array(id)) FROM range(0, 4)
+
+-- The item divides by zero exactly on the row whose array is NULL, so Spark 
raises and the
+-- dispatcher raises with it.
+query expect_error_class(DIVIDE_BY_ZERO)
+SELECT _1, array_append(arr, 1 / (_1 - 1)) AS a
+FROM test_array_append_ansi
+
+-- The divide raises on its own, so the error above is not an artifact of the 
fixture.
+query expect_error(DIVIDE_BY_ZERO)
+SELECT _1, 1 / (_1 - 1) AS d
+FROM test_array_append_ansi
+
+-- A column or literal item cannot raise, so a nullable array keeps the native 
guarded path.
+query expect_native(array_append)
+SELECT _1, array_append(arr, _1) AS a
+FROM test_array_append_ansi
+
+query expect_native(array_append)
+SELECT _1, array_append(arr, 7) AS a
+FROM test_array_append_ansi
+
+query expect_native(array_append)
+SELECT _1, array_append(arr, CAST(NULL AS INT)) AS a
+FROM test_array_append_ansi
+
+-- Any other item over a nullable array is routed through the dispatcher, even 
one that cannot
+-- raise on this data.
+query expect_dispatch(array_append)
+SELECT _1, array_append(arr, _1 + 1) AS a
+FROM test_array_append_ansi
+
+-- A non-nullable array literal cannot hit the gap and keeps the native 
guarded path.
+query expect_native(array_append)
+SELECT _1, array_append(array(1), _1) AS a
+FROM test_array_append_ansi
+
+query expect_native(array_append)
+SELECT _1, array_append(array(1), _1 + 1) AS a
+FROM test_array_append_ansi

Review Comment:
   The routing queries here (column item, literal item, `_1 + 1` dispatched, 
non-nullable array native) are the same as the ones in 
`array_append_null_array_item.sql`, and both headers say the gate does not 
depend on ANSI. Only the `1 / (_1 - 1)` pair, the `CAST(NULL AS INT)` item and 
the `array(1), _1` case are specific to this file or missing from the other 
one. Could this file keep those and the matrix live once, or could both modes 
run from one file with `element_at`, which raises in both?



##########
spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala:
##########
@@ -1347,10 +1352,53 @@ object QueryPlanSerde extends Logging with 
CometExprShim with CometTypeShim {
     val later = expr.children.tail
     // Subexpression elimination never shares a leaf, such as a column or a 
literal
     later.forall(_.isInstanceOf[LeafExpression]) || 
sharedSubexpressions.value.exists { s =>
-      !later.exists(_.exists(e => s.common.exists(_.semanticEquals(e))))
+      !holdsSharedSubexpression(later, s)
     }
   }
 
+  /**
+   * Whether some node of `expr` with more than one child holds, in a child it 
can skip, a
+   * subexpression that the operator's expressions share. A node can skip any 
child after its
+   * first, a `DivModLike` or `Pmod` its dividend too, a `StringLocate`, which 
evaluates its start
+   * first, its other two children, and an `ArrayJoin` with a nullable 
replacement, which
+   * evaluates that first, its array and delimiter, while an `ArrayAppend` 
skips none. Spark
+   * evaluates that subexpression for every row, while `expr` evaluated alone, 
as the JVM codegen
+   * dispatcher does, can skip it on the rows where a child evaluated before 
it is NULL, so an
+   * ANSI error Spark raises goes unraised. With 
`spark.sql.subexpressionElimination.enabled` off
+   * only an aggregation shares subexpressions (see `sharedSubexpressions`). A 
subexpression that
+   * `expr` itself repeats does not count while the setting is on: the 
dispatcher then eliminates
+   * it too, and evaluates it for every row. False outside 
`withSharedSubexpressions`, where what
+   * the operator shares is unknown.
+   */
+  def skipsSharedSubexpression(expr: Expression): Boolean =
+    sharedSubexpressions.value.exists { s =>
+      // The dispatcher eliminates subexpressions only while the setting is on
+      lazy val repeated =
+        if (SQLConf.get.subexpressionEliminationEnabled) 
commonSubexpressions(Seq(expr))
+        else Nil
+      expr.exists { node =>
+        val skippable = node match {
+          // `DivModLike` and `Pmod` skip the dividend when the divisor, 
evaluated first, is NULL
+          case _: DivModLike | _: Pmod => node.children

Review Comment:
   The comment says `DivModLike` and `Pmod` skip the dividend, but the branch 
returns `node.children`, which includes the divisor. In Spark 3.5.9 
`DivModLike.doGenCode` evaluates `eval2` (the divisor) first and puts only 
`eval1` (the dividend) inside the branch it skips, and `eval` says the same, so 
the divisor is never skipped. The effect is only a decline where Spark would 
not skip anything, for a shared subexpression that sits only in the divisor. Is 
that intended? If not, `node.children.take(1)` matches the comment. I read 
3.5.9 only.



##########
spark/src/test/resources/sql-tests/expressions/misc/codegen_dispatch_calendar_interval_output.sql:
##########
@@ -0,0 +1,83 @@
+-- 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.
+
+-- The JVM codegen dispatcher writes a calendar interval's microseconds to 
Arrow as nanoseconds,
+-- which overflows a long beyond about 292 years, a range Spark represents
+-- (https://github.com/apache/datafusion-comet/issues/5279). An expression 
that would only
+-- run through the dispatcher declines it when its result holds a calendar 
interval at any
+-- nesting depth, so the projection falls back to Spark. The small values fall 
back as well,
+-- since the decision is made on the type.
+
+statement
+CREATE TABLE test_interval_output_big(s decimal(18, 6)) USING parquet
+
+statement
+INSERT INTO test_interval_output_big VALUES (10000000000.000000), 
(-10000000000.000000), (NULL)
+
+statement
+CREATE TABLE test_interval_output_small(s decimal(18, 6)) USING parquet
+
+statement
+INSERT INTO test_interval_output_small VALUES (1.000000), (-2.500001), (NULL)
+
+query expect_fallback(holds a calendar interval)
+SELECT arrays_zip(array(make_interval(0, 0, 0, 0, 0, 0, s))) FROM 
test_interval_output_big
+
+-- An interval inside a struct inside the zipped array.
+query expect_fallback(holds a calendar interval)
+SELECT arrays_zip(array(named_struct('i', make_interval(0, 0, 0, 0, 0, 0, 
s)))) FROM test_interval_output_big
+
+-- An interval as a map value inside the zipped array.
+query expect_fallback(holds a calendar interval)
+SELECT arrays_zip(array(map(1, make_interval(0, 0, 0, 0, 0, 0, s)))) FROM 
test_interval_output_big
+
+-- A floating-point map key has no native lookup, so these would dispatch.
+query expect_fallback(holds a calendar interval)
+SELECT element_at(map_from_arrays(array(1.0D), array(make_interval(0, 0, 0, 0, 
0, 0, s))), 1.0D)
+FROM test_interval_output_big
+
+query expect_fallback(holds a calendar interval)
+SELECT map_from_arrays(array(1.0D), array(make_interval(0, 0, 0, 0, 0, 0, 
s)))[1.0D]
+FROM test_interval_output_big
+
+-- Native map construction compares floating-point keys by bits, so this would 
dispatch.
+query expect_fallback(holds a calendar interval)
+SELECT map_from_arrays(array(1.0D), array(make_interval(0, 0, 0, 0, 0, 0, s)))
+FROM test_interval_output_big
+
+-- A nondeterministic child has no native path, so this would dispatch.
+query expect_fallback(holds a calendar interval)
+SELECT map_from_arrays(array(monotonically_increasing_id()), 
array(make_interval(0, 0, 0, 0, 0, 0, s)))
+FROM test_interval_output_big
+
+query expect_fallback(holds a calendar interval)
+SELECT arrays_zip(array(make_interval(0, 0, 0, 0, 0, 0, s))) FROM 
test_interval_output_small
+
+query expect_fallback(holds a calendar interval)
+SELECT arrays_zip(array(named_struct('i', make_interval(0, 0, 0, 0, 0, 0, 
s)))) FROM test_interval_output_small

Review Comment:
   The header (line 22) says the decision is made on the type, so these two 
queries on `test_interval_output_small` repeat the first two on 
`test_interval_output_big` and cannot catch anything those miss. The only use 
of the small table that differs is the `expect_dispatch` control on line 75, 
and `map(1, s)` has no interval, so it works on the big table too. Could the 
small table and these two queries go?



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to