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


##########
spark/src/main/scala/org/apache/comet/serde/arrays.scala:
##########
@@ -1250,7 +1445,31 @@ trait ArraysBase {
 
 object CometArrayTransform extends CometCodegenDispatch[ArrayTransform]
 
-object CometArrayExists extends CometCodegenDispatch[ArrayExists]
+/**
+ * `exists(arr, x -> body)`. When the body is made of natively supported 
expressions it runs under
+ * DataFusion's `array_any_match`, which returns what Spark returns with 
three-valued logic: true
+ * for a match, NULL when none matches but the predicate was NULL for some 
element, false
+ * otherwise. Spark returns false instead of NULL in that last case when
+ * `spark.sql.legacy.followThreeValuedLogicInArrayExists` is false, so 
`exists` stays on the
+ * dispatcher with that setting.
+ */
+object CometArrayExists extends CometCodegenDispatch[ArrayExists] with 
NativeLambdaSerde {
+
+  override def convert(
+      expr: ArrayExists,
+      inputs: Seq[Attribute],
+      binding: Boolean): Option[ExprOuterClass.Expr] = {
+    expr.function match {
+      case LambdaFunction(body, Seq(lambdaVar: NamedLambdaVariable), _)
+          if binding && expr.followThreeValuedLogic && nativeLambdaSupported(
+            expr.argument,
+            body) =>

Review Comment:
   Spark's `ArrayExists` stops at the first matching element (`while (i < 
arr.numElements && !exists)`, the same in 3.4.3, 3.5.8, 4.0.1 and main). 
`array_any_match` evaluates the body over every element of the batch first. 
This is the case `shortCircuitSafe` handles for `AND`, `OR` and `IN`, except 
that `exists` skips elements without any of them, so the gate passes.
   
   With ANSI on, which is the default on Spark 4, `exists(ints, x -> 100 / x > 
1)` over `array(1, 0)` returns true in Spark and should raise `DIVIDE_BY_ZERO` 
natively. With ANSI off, `element_at` with the index 0 behaves the same way. I 
have not run either.
   
   Could `exists` also require `!body.exists(mayRaise)`, whatever operators the 
body uses? That query would then go into an ANSI fixture as 
`expect_dispatch(exists)`, and the tuning page bullet would cover `exists`. 
`filter` evaluates every element, so its current rule is enough.



##########
spark/src/main/scala/org/apache/comet/serde/arrays.scala:
##########
@@ -1026,7 +1029,133 @@ object CometFlatten extends 
CometExpressionSerde[Flatten] with ArraysBase {
   }
 }
 
-object CometArrayFilter extends CometExpressionSerde[ArrayFilter] {
+/**
+ * What a serde needs to run the lambda of a higher-order function natively, 
under a DataFusion
+ * higher-order function: the gate on the lambda and its body, and the 
serialization.
+ */
+trait NativeLambdaSerde extends CometTypeShim with CometExprShim {
+
+  // The native lambda has the same semantics as Spark's `filter` and `exists` 
for a one-parameter
+  // lambda whose body is deterministic, contains no nested lambda, and whose 
parameter is a type
+  // the native side can type-check against the list's element field. It needs 
bound references:
+  // the native side re-indexes the columns the body captures, which it cannot 
do for unbound ones.
+  protected def nativeLambdaSupported(argument: Expression, body: Expression): 
Boolean =
+    CometConf.COMET_NATIVE_LAMBDA_ENABLED.get() &&
+      body.deterministic &&
+      !body.exists(_.isInstanceOf[HigherOrderFunction]) &&
+      capturesOnlyFixedWidthColumns(body) &&
+      shortCircuitSafe(body) &&
+      !castsToMap(argument) &&
+      (argument.dataType match {
+        case ArrayType(elementType, _) => isLambdaElementType(elementType)
+        case _ => false
+      })
+
+  /**
+   * Whether `argument` casts to a type that holds a map. A TRY cast of a map 
whose key cast can
+   * fail makes a null key, which Arrow cannot hold (#6172), so such an 
argument stays on the
+   * dispatcher, which falls back to Spark for it.
+   */
+  private def castsToMap(argument: Expression): Boolean = {
+    def holdsMap(dt: DataType): Boolean = dt match {
+      case _: MapType => true
+      case ArrayType(element, _) => holdsMap(element)
+      case StructType(fields) => fields.exists(f => holdsMap(f.dataType))
+      case _ => false
+    }
+    argument.exists {
+      case c: Cast => holdsMap(c.dataType)
+      case _ => false
+    }
+  }

Review Comment:
   `CometCast.canProduceNullMapKey` is already the #6172 predicate (a TRY cast 
whose key cast can fail), and `CometBatchKernelCodegen.canHandle` uses it. 
`holdsMap` also matches casts that cannot make a null key, such as the widening 
one in `codegen_dispatch_null_map_key.sql`. Reusing it, with an 
`org.apache.comet.expressions.CometCast` import:
   
   ```suggestion
     private def castsToMap(argument: Expression): Boolean =
       argument.exists {
         case c: Cast => CometCast.canProduceNullMapKey(c)
         case _ => false
       }
   ```
   
   The Scaladoc also says the dispatcher falls back to Spark for such an 
argument. But that file pins the `exists(map_keys(try_cast(...)), ...)` query 
with `expect_dispatch(exists)`: the dispatcher runs it, because the map never 
leaves Spark's code.



##########
docs/source/user-guide/latest/expressions.md:
##########
@@ -388,7 +388,7 @@ to Spark 
([#2837](https://github.com/apache/datafusion-comet/issues/2837)). Enab
 | `aggregate` | ✅ | Codegen dispatch |  |
 | `array_sort` | ✅ | Native | The default comparator runs natively; custom 
comparators and arrays with non-default string collations use the JVM codegen 
dispatcher |
 | `exists` | ✅ | Codegen dispatch |  |

Review Comment:
   The Implementation column is generated by `GenerateDocs.classifySerde`. It 
still says Codegen dispatch here because `CometArrayExists` extends 
`CometCodegenDispatch`, and the Notes cell is empty. Could the Notes say what 
the `filter` row says, with the same link to Expression Tuning? On that page, 
the capture bullet leaves out decimal, which `isFixedWidth` accepts.



##########
native/spark-expr/src/conditional_funcs/null_short_circuit.rs:
##########
@@ -290,27 +290,61 @@ pub(super) fn evaluate_projected(
 }
 
 /// The input columns that `expr` reads, and `expr` reading them from a batch 
of just those columns.
+///
+/// A [`LambdaVariable`] reads a column of the batch its lambda is evaluated 
on exactly as a
+/// [`Column`] does, so it is projected and re-indexed the same way.
 pub(super) fn project(expr: &Arc<dyn PhysicalExpr>) -> Result<(Vec<usize>, 
Arc<dyn PhysicalExpr>)> {
-    let mut projection = collect_columns(expr)
-        .iter()
-        .map(Column::index)
-        .collect::<Vec<_>>();
+    // A lambda inside `expr` binds its parameters: they are not columns of 
the batch `expr` is
+    // evaluated on, so they are neither projected nor re-indexed. Lambda 
parameter names are unique
+    // per lambda, so no name is both bound inside `expr` and free in it.
+    let mut bound: HashSet<String> = HashSet::new();
+    expr.apply(|e| {
+        if let Some(lambda) = e.downcast_ref::<LambdaExpr>() {
+            bound.extend(lambda.params().iter().cloned());
+        }
+        Ok(TreeNodeRecursion::Continue)
+    })?;
+    let mut projection = vec![];
+    expr.apply(|e| {
+        if let Some(column) = e.downcast_ref::<Column>() {
+            projection.push(column.index());
+        } else if let Some(variable) = e.downcast_ref::<LambdaVariable>() {
+            if !bound.contains(variable.name()) {
+                projection.push(variable.index());
+            }
+        }
+        Ok(TreeNodeRecursion::Continue)
+    })?;

Review Comment:
   `apply` visits a node before its children, and a bound `LambdaVariable` 
always sits below its `LambdaExpr`. So `bound` is already complete when a 
variable is reached, and the two walks can be one:
   
   ```suggestion
       // `apply` visits each lambda before the variables it binds.
       let mut bound: HashSet<String> = HashSet::new();
       let mut projection = vec![];
       expr.apply(|e| {
           if let Some(lambda) = e.downcast_ref::<LambdaExpr>() {
               bound.extend(lambda.params().iter().cloned());
           } else if let Some(column) = e.downcast_ref::<Column>() {
               projection.push(column.index());
           } else if let Some(variable) = e.downcast_ref::<LambdaVariable>() {
               if !bound.contains(variable.name()) {
                   projection.push(variable.index());
               }
           }
           Ok(TreeNodeRecursion::Continue)
       })?;
   ```



##########
native/core/src/execution/planner.rs:
##########
@@ -5285,6 +5346,225 @@ mod tests {
         }
     }
 
+    /// A higher-order function as the right operand of OR or AND: DataFusion 
evaluates the right
+    /// operand over only the rows the left operand leaves open.
+    #[test]
+    fn test_higher_order_as_right_operand() -> Result<(), DataFusionError> {
+        use arrow::array::{BooleanArray, ListBuilder, StringBuilder};
+        use datafusion_comet_proto::spark_expression::{
+            BinaryExpr, BoundReference, HigherOrderFunc, LambdaVariable,
+        };
+
+        fn expr(expr_struct: ExprStruct) -> spark_expression::Expr {
+            spark_expression::Expr {
+                expr_struct: Some(expr_struct),
+                query_context: None,
+                expr_id: None,
+            }
+        }
+        fn bound(index: i32) -> spark_expression::Expr {
+            expr(Bound(BoundReference {
+                index,
+                datatype: None,
+            }))
+        }
+        fn binary(left: spark_expression::Expr, right: spark_expression::Expr) 
-> Box<BinaryExpr> {
+            Box::new(BinaryExpr {
+                left: Some(Box::new(left)),
+                right: Some(Box::new(right)),
+            })
+        }
+        // array_any_match(a, x#1 -> x#1 = c)
+        let any_match = 
expr(ExprStruct::HigherOrderFunc(Box::new(HigherOrderFunc {
+            func: "array_any_match".to_string(),
+            array: Some(Box::new(bound(0))),
+            param_name: "x#1".to_string(),
+            body: Some(Box::new(expr(ExprStruct::Eq(binary(
+                expr(ExprStruct::LambdaVariable(LambdaVariable {
+                    name: "x#1".to_string(),
+                })),
+                bound(1),
+            ))))),
+        })));
+
+        let mut builder = ListBuilder::new(StringBuilder::new());
+        for row in [vec!["a"], vec!["b"], vec!["a"], vec!["z"]] {
+            row.into_iter()
+                .for_each(|v| builder.values().append_value(v));
+            builder.append(true);
+        }
+        let list = builder.finish();
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("a", list.data_type().clone(), true),
+            Field::new("c", DataType::Utf8, true),
+            Field::new("b", DataType::Boolean, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(list),
+                Arc::new(StringArray::from(vec!["a", "a", "x", "z"])),
+                Arc::new(BooleanArray::from(vec![true, false, false, false])),
+            ],
+        )?;
+        let session_ctx = SessionContext::new();
+        let planner = PhysicalPlanner::new(Arc::from(session_ctx), 0);
+
+        // b OR any_match(...): rows 0 and 3 are true, the others have no 
match.
+        let or = expr(ExprStruct::Or(binary(bound(2), any_match.clone())));
+        let physical = planner.create_expr(&or, Arc::clone(&schema)).unwrap();
+        let result = physical.evaluate(&batch)?.into_array(batch.num_rows())?;
+        let expected = BooleanArray::from(vec![true, false, false, true]);
+        assert_eq!(&result, &(Arc::new(expected) as ArrayRef));
+
+        // NOT b AND any_match(...): rows 1 and 2 are false, row 3 matches.
+        let and = expr(ExprStruct::And(binary(
+            expr(ExprStruct::Not(Box::new(
+                datafusion_comet_proto::spark_expression::UnaryExpr {
+                    child: Some(Box::new(bound(2))),
+                },
+            ))),
+            any_match,
+        )));
+        let physical = planner.create_expr(&and, Arc::clone(&schema)).unwrap();
+        let result = physical.evaluate(&batch)?.into_array(batch.num_rows())?;
+        let expected = BooleanArray::from(vec![false, false, false, true]);
+        assert_eq!(&result, &(Arc::new(expected) as ArrayRef));
+        Ok(())
+    }
+
+    #[test]
+    fn test_higher_order_filter() -> Result<(), DataFusionError> {
+        use arrow::array::BooleanArray;
+        use arrow::array::ListBuilder;
+        use arrow::array::StringBuilder;
+        use datafusion_comet_proto::spark_expression::{
+            BinaryExpr, BoundReference, HigherOrderFunc, LambdaVariable,
+        };
+
+        fn expr(expr_struct: ExprStruct) -> spark_expression::Expr {
+            spark_expression::Expr {
+                expr_struct: Some(expr_struct),
+                query_context: None,
+                expr_id: None,
+            }
+        }
+        fn bound(index: i32) -> spark_expression::Expr {
+            expr(Bound(BoundReference {
+                index,
+                datatype: None,
+            }))
+        }
+        fn variable(name: &str) -> spark_expression::Expr {
+            expr(ExprStruct::LambdaVariable(LambdaVariable {
+                name: name.to_string(),
+            }))
+        }
+        fn string_lists(rows: Vec<Option<Vec<Option<&str>>>>) -> ListArray {
+            let mut builder = ListBuilder::new(StringBuilder::new());
+            for row in rows {
+                match row {
+                    Some(values) => {
+                        values
+                            .into_iter()
+                            .for_each(|v| builder.values().append_option(v));
+                        builder.append(true);
+                    }
+                    None => builder.append(false),
+                }
+            }
+            builder.finish()
+        }
+
+        // array_filter(a, x#1 -> x#1 = c) over a: array<string> and c: string
+        fn filter(func: &str, array: spark_expression::Expr) -> 
spark_expression::Expr {
+            expr(ExprStruct::HigherOrderFunc(Box::new(HigherOrderFunc {
+                func: func.to_string(),
+                array: Some(Box::new(array)),
+                param_name: "x#1".to_string(),
+                body: Some(Box::new(expr(ExprStruct::Eq(Box::new(BinaryExpr {
+                    left: Some(Box::new(variable("x#1"))),
+                    right: Some(Box::new(bound(1))),
+                }))))),
+            })))
+        }
+
+        let list = string_lists(vec![
+            Some(vec![Some("a"), None, Some("b"), Some("a")]),
+            Some(vec![]),
+            None,
+            Some(vec![Some("b")]),
+        ]);
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("a", list.data_type().clone(), true),
+            Field::new("c", DataType::Utf8, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(list),
+                Arc::new(StringArray::from(vec!["a", "a", "a", "x"])),
+            ],
+        )?;
+
+        let session_ctx = SessionContext::new();
+        let planner = PhysicalPlanner::new(Arc::from(session_ctx), 0);
+
+        // The lambda keeps the elements equal to the captured column, and 
drops NULL elements.
+        let physical = planner
+            .create_expr(&filter("array_filter", bound(0)), 
Arc::clone(&schema))
+            .unwrap();
+        let result = physical.evaluate(&batch)?.into_array(batch.num_rows())?;
+        let expected = string_lists(vec![
+            Some(vec![Some("a"), Some("a")]),
+            Some(vec![]),
+            None,
+            Some(vec![]),
+        ]);
+        assert_eq!(&result, &(Arc::new(expected) as ArrayRef));
+
+        // array_any_match(a, x#1 -> x#1 = c) is Spark's `exists`: true when 
an element matches, NULL
+        // when none does but the predicate was NULL for some element, false 
otherwise, and NULL for a
+        // NULL array.

Review Comment:
   This half checks the three-valued result again. DataFusion's own 
`array_any_match` tests already cover it, and `array_exists_native_lambda.sql` 
covers it against Spark. `expr` and `bound` are also defined again in 
`test_higher_order_as_right_operand`. Could this test keep the planning checks 
(the `array_filter` result, the non-array argument, the unknown function), with 
the two helpers at module level?



##########
spark/src/test/scala/org/apache/comet/CometCodegenHOFSuite.scala:
##########
@@ -98,15 +100,22 @@ class CometCodegenHOFSuite
 
   test("ArrayExists respects three-valued logic through codegen dispatcher") {
     withArrayIntTable("(array(1, null, 3)), (array(1, 2, 3)), (array()), 
(null)") {
-      Seq(false, true).foreach { followThreeValuedLogic =>
-        withSQLConf(
-          "spark.sql.legacy.followThreeValuedLogicInArrayExists" ->
-            followThreeValuedLogic.toString) {
-          assertCodegenRan {
-            checkSparkAnswerAndOperator(sql("SELECT exists(a, x -> x > 10) 
FROM t"))
-          }
+      // With the legacy setting off Spark returns false where DataFusion's 
array_any_match returns
+      // NULL, so only the dispatcher runs `exists`. With it on the lambda 
runs natively and the
+      // dispatcher stays idle, with the same answer as Spark.
+      withSQLConf("spark.sql.legacy.followThreeValuedLogicInArrayExists" -> 
"false") {
+        assertCodegenRan {
+          checkSparkAnswerAndOperator(sql("SELECT exists(a, x -> x > 10) FROM 
t"))
         }
       }
+      withSQLConf("spark.sql.legacy.followThreeValuedLogicInArrayExists" -> 
"true") {
+        CometScalaUDFCodegen.resetStats()
+        checkSparkAnswerAndOperator(sql("SELECT exists(a, x -> x > 10) FROM 
t"))
+        val after = CometScalaUDFCodegen.stats()
+        assert(
+          after.compileCount + after.cacheHitCount == 0,
+          s"expected exists to run natively, but the dispatcher ran: $after")
+      }

Review Comment:
   The `true` block asserts what `expect_native(exists)` in 
`array_exists_native_lambda.sql` already pins. The `false` block asserts what 
`array_exists_native_lambda_two_valued.sql` pins with 
`expect_dispatch(exists)`. As with the `filter` tests in the first round, could 
one copy go, preferably this test and the `CometScalaUDFCodegen` import?
   
   Also, `filter(ints, x -> nvl(x, '') != '')` in 
`array_filter_native_lambda.sql` now runs as `array_compact`. Its comment about 
casting each element is stale, and the query repeats 
`array_filter_numeric_nvl.sql`.



-- 
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