andygrove commented on code in PR #7322:
URL: https://github.com/apache/datafusion-comet/pull/7322#discussion_r4492892182
##########
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:
You're right. I ran both before the fix: with ANSI on, `exists(ints, x ->
100 / x > 1)` over `array(1, 0)` raised `DIVIDE_BY_ZERO` natively, and with
ANSI off `exists(ints, x -> element_at(array(threshold, 5), x) > 1)` raised
`INVALID_INDEX_OF_ZERO`. Spark returns true for both.
`exists` now also requires `!body.exists(mayRaise)`, whatever operators the
body uses. The new `array_exists_native_lambda_ansi.sql` pins your query, an
ANSI addition that overflows, an ANSI cast of a string and a division inside
`CASE` with `expect_dispatch(exists)`. Each of them raises only on an element
after the first match. Two bodies that cannot raise are pinned with
`expect_native(exists)`. The `element_at` query is in the non-ANSI file. The
date-conversion query there moved to `expect_dispatch(exists)` as well, because
`timestamp_seconds` of a BIGINT raises on overflow. The tuning page has a
bullet for `exists`. Fixed in 3bb85cfc1b.
##########
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:
Agreed. It uses `CometCast.canProduceNullMapKey` now, and I renamed the
helper to `canProduceNullMapKey`, since it no longer looks at the target type.
The Scaladoc now says the dispatcher runs such an argument in Spark's own code.
I added the widening cast to `codegen_dispatch_null_map_key.sql` as an
`expect_native(exists)` query, next to the dispatched one. Fixed in 3bb85cfc1b.
##########
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:
Done, it's one walk now, and your comment is folded into the existing one.
Fixed in 3bb85cfc1b.
##########
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 Notes cell now matches the `filter` row, with the conditions that only
apply to `exists`, and it has the same link. I also made `CometArrayExists` a
plain `CometExpressionSerde` that calls the dispatcher itself, as
`CometArrayFilter` does, so the generated Implementation column says Native for
both. The capture bullet lists decimal now. Fixed in 3bb85cfc1b.
##########
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:
Removed the test and the `CometScalaUDFCodegen` import. I also removed the
numeric `nvl` query and its stale comment from
`array_filter_native_lambda.sql`, since `array_filter_numeric_nvl.sql` covers
it. Fixed in 3bb85cfc1b.
##########
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:
Done. `test_higher_order_filter` keeps the `array_filter` result and the two
error cases, and `expr` and `bound` are module-level helpers that both tests
use. Fixed in 3bb85cfc1b.
--
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]