Repository navigation
Conversation
A filter lambda other than `x IS NOT NULL` went through the JVM codegen dispatcher, which evaluates it one element at a time like Spark does, plus the transfer of each batch. A body made of natively supported expressions now runs once over all the elements of a batch through DataFusion's array_filter. The native path applies to a one-parameter lambda over an array of a primitive, decimal, string, binary, date or timestamp element type. These stay on the dispatcher: nested lambdas, nondeterministic bodies, bodies that call out to the JVM, non-default string collations, and under ANSI mode a body that short-circuits (AND, OR, IN) around an operation that can raise, since the native operators evaluate every operand. The new spark.comet.exec.nativeLambda.enabled turns the native path off. Adds a lambda tuning section to the user guide and a benchmark. Closes apache#7321.
|
cc @hsiang-c |
comphead
left a comment
There was a problem hiding this comment.
DataFusion 55.2's HigherOrderFunctionExpr calls remove_list_null_values before it evaluates the body, and list_values slices from the first offset to the last, so values under a NULL array or outside a sliced range never reach the lambda. A NULL predicate drops the element and a NULL array stays NULL, as in Spark's ArrayFilter, and captured columns are re-indexed correctly. Three comments.
| // 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. | ||
| private def nativeLambdaSupported(expr: ArrayFilter, body: Expression): Boolean = |
There was a problem hiding this comment.
DataFusion's lambda copies every captured column once per element before evaluating the body: evaluate_single_list_lambda in datafusion-functions-nested 55.2 spreads the captures with take_arrays(arrays, &list_values_row_number(&original_list)?). For an INT capture such as threshold, the shape the benchmark measures, that is cheap. For a string, binary or array capture the copy grows with the value size. filter(a, x -> array_contains(b, x)) over 100-element INT arrays copies b 100 times per row, roughly 330 MB for one 8192-row batch by my arithmetic, and as far as I can tell those allocations are not reserved with the memory pool. Spark reads the captured value in place, and DuckDB's list_filter references captures through a selection vector. Could the native path decline when the body captures a column that is not fixed-width, or could the benchmark add a captured string or array case to show where it stops paying off? Not measured.
There was a problem hiding this comment.
Good catch, and it applies to more than array_contains. Any captured string, binary, array, struct or map column is copied once per element before the body runs. I haven't checked the memory-pool accounting myself, but declining the native path for these captures sidesteps the question.
nativeLambdaSupported now declines when the body reads a captured column that isn't fixed-width, so those lambdas stay on the dispatcher. Numeric, boolean, date, timestamp and decimal captures still run natively, which is the case the benchmark measures. I didn't add a captured-string benchmark case, because that case now takes the dispatcher path and would only measure the old behaviour. Fixed in a16bdba. The tuning page now says to capture only fixed-width columns.
| * unless every expression in it is one that cannot raise. | ||
| */ | ||
| private def shortCircuitSafe(body: Expression): Boolean = | ||
| !SQLConf.get.ansiEnabled || |
There was a problem hiding this comment.
Some natively supported expressions raise with ANSI off too. Spark's ElementAt throws INVALID_INDEX_OF_ZERO for index 0 outside the failOnError branch, and Comet's one_based_index in list_extract.rs does the same. So with spark.sql.ansi.enabled=false, filter(ints, x -> x > 0 AND element_at(other, x) > 1) takes the native path, where Spark never evaluates element_at(other, 0) because And stops at x > 0. DataFusion's BinaryExpr pre-selects rows for the right side only when the left side is at most 20% true (PRE_SELECTION_THRESHOLD), so for array(0, 1, 2) it evaluates element_at on every element and raises. I have not run it. Could the check classify each node instead, treating ElementAt as raising in any mode and judging arithmetic and casts by their own evalMode, with that query in the non-ANSI fixture?
There was a problem hiding this comment.
You're right. The ANSI check missed expressions that raise in every mode, and element_at with the index 0 is one.
The check no longer looks at the session's ANSI setting. It classifies each expression and fails closed. Arithmetic and casts count as able to raise only when they carry the ANSI eval mode, and anything not on the known-safe list, including element_at, counts as able to raise. A body that combines AND, OR or IN with such an expression stays on the dispatcher. Non-ANSI bodies like x > 0 AND x % 3 = 0 still run natively.
I added your case to the non-ANSI fixture as v > 0 AND element_at(array(threshold, 5), v) > 1. I used a fixed-width array instead of the other column from your example, because a captured array now takes the dispatcher for the reason in the first thread, which would hide this rule. It is pinned with expect_dispatch(filter) and matches Spark. Fixed in a16bdba.
| -- `array_filter`. Each query below must return what Spark returns, including for NULL arrays, | ||
| -- empty arrays, NULL elements and lambdas that capture a column. | ||
|
|
||
| -- ConfigMatrix: spark.comet.exec.nativeLambda.enabled=true,false |
There was a problem hiding this comment.
A plain query passes whether the lambda ran natively or in the dispatcher, so this file cannot show which path a query took, and the false arm runs the same queries through the dispatcher a second time. sql-file-tests.md documents query expect_native(...) and query expect_dispatch(...) for a serde that splits between the two paths. With expect_native(filter) on the native queries and expect_dispatch(filter) on the RLIKE, nested-lambda and two-parameter ones, here and in _ansi.sql, the ConfigMatrix and the three new CometCodegenHOFSuite tests could go, since they assert the same split through dispatcher counters. isLambdaElementType also admits tinyint and smallint elements, but the table has no column of either type.
There was a problem hiding this comment.
Agreed, a plain query couldn't show which path ran. Every query in array_filter_native_lambda.sql, the _ansi file and the new captured-column file now carries expect_native(filter) or expect_dispatch(filter). The nativeLambda ConfigMatrix is gone. A small array_filter_native_lambda_disabled.sql checks that the kill switch sends everything to the dispatcher. The three new CometCodegenHOFSuite tests and the assertNoCodegen helper I had added are removed, so those two files match apache/main again.
I also added tinyint and smallint columns, since isLambdaElementType admits both and nothing exercised them. Fixed in a16bdba.
…on arguments NullShortCircuit evaluates the later arguments of a function such as array_contains on a projection of just the columns they read. It re-indexed only Column, so a lambda parameter passed as such an argument, as in filter(ints, x -> array_contains(other, x)), was dropped from the projection and the query failed. Treat LambdaVariable like Column when projecting, and like Column in case_when's infallible check. The JVM codegen dispatcher now declines a tree that reads a lambda parameter bound outside it. Its kernel is fed only columns, so the parameter read as NULL. The filter serde no longer walks the serialized body to find such a call. Also simplify the planner arm test, share assertNoCodegen between the codegen suites, and tidy the benchmark.
…s that can raise in any mode DataFusion copies each column a lambda captures once per element of the array, so a captured string, binary or nested column is copied as many times as the array has elements. A body that reads one now stays on the dispatcher. The check for a body that short-circuits (AND, OR, IN) no longer depends on the ANSI setting. It classifies each expression: arithmetic and casts by the eval mode they carry, and anything not known to be safe, such as element_at, which raises for the index 0 in any mode, as able to raise. The SQL tests pin the path of each query with expect_native and expect_dispatch, replacing the ConfigMatrix over the native lambda flag and the codegen suite tests, and cover tinyint and smallint elements.
|
Thanks for the review. All three are addressed in a16bdba, and the PR description is updated to match. The Spark SQL suite is still running through the |
On a numeric, date or timestamp array Spark plans the customer's rule filter(arr, x -> nvl(x, '') != '') as NOT (coalesce(CAST(x AS STRING), '') = ''), in either operand order. No such cast is empty, so the predicate holds exactly when x is not NULL, and the lambda is x IS NOT NULL. Send it down the array_compact fast path instead of casting every element to a string. On 2M rows of ten-element int arrays (every fifth element NULL), Spark 3.5.5 local[4], best of 5: 0.453 s on the dispatcher, 0.281 s with the native lambda, 0.086 s with this change, against 0.220 s for Spark.
…push # Conflicts: # native/spark-expr/src/conditional_funcs/null_short_circuit.rs
rich7420
left a comment
There was a problem hiding this comment.
Component repro with the production projection functions: base passes; head fails with project index 2 out of bounds. Full Spark query not run.
| if let Some(column) = e.downcast_ref::<Column>() { | ||
| projection.push(column.index()); | ||
| } else if let Some(variable) = e.downcast_ref::<LambdaVariable>() { | ||
| projection.push(variable.index()); |
There was a problem hiding this comment.
For array_contains(a, size(filter(b, x -> x > 1))), this adds the inner lambda parameter to the outer projection, causing an out-of-bounds error. Preserve lambda scope when collecting and remapping columns.
There was a problem hiding this comment.
Good catch. project() collected every LambdaVariable as a column of the outer batch, but a lambda inside the projected expression binds its own parameter, which is not a column of that batch, so batch.project was asked for an index past the end. I reproduced it with a higher-order function as the right operand of AND and OR, which goes through the same function, and the same applied to filter.
project() now skips a variable whose lambda is inside the expression it projects, and leaves it unchanged when it re-indexes. Lambda parameter names are unique per lambda (x#id), so collecting the names a lambda in the expression binds is enough to tell them apart from the free variables that the earlier fix needs, where a short-circuited argument inside a lambda body reads the lambda's own parameter.
Tests: your query, array_contains(ints, size(filter(ints, x -> x > 1))), and AND/OR with a lambda on the right are in array_filter_native_lambda.sql, and a Rust planner test covers AND and OR. I also compared these shapes against Spark with ANSI on and off. Fixed in 1656959.
CometArrayExists now serializes a lambda whose body Comet supports natively as a HigherOrderFunc under array_any_match, as filter does with array_filter, when three-valued logic is on. The gate and the serialization move into a trait both serdes share. exists stays on the dispatcher with spark.sql.legacy.followThreeValuedLogicInArrayExists=false, since Spark returns false where array_any_match returns NULL, and when the array argument contains a cast to a map, whose TRY cast can produce a null key that Arrow cannot hold (apache#6172). Also fixes a higher-order function inside an expression that is evaluated only for some rows: as the right operand of AND or OR, or inside an argument of a function like array_contains. These failed with 'project index N out of bounds', because project() in null_short_circuit.rs collected the function's own lambda parameter as a column of the outer batch. It now ignores variables bound by a lambda inside the expression it projects. This affected filter as well as exists.
|
#6485 this might be good fit for such things, but requires way more work and testing |
|
I'll try to adopt this example for case study and see if we can get some benefits |
comphead
left a comment
There was a problem hiding this comment.
Second pass at 66a1066. The three earlier points are addressed. The lambda-scope fix in project() also holds: DataFusion 55.2's HigherOrderFunctionExpr::evaluate keeps only the lambda.projection() indexes below batch.num_columns(), so an inner parameter that keeps its original index still resolves after the outer projection. One correctness point on exists, and a few smaller ones.
| case LambdaFunction(body, Seq(lambdaVar: NamedLambdaVariable), _) | ||
| if binding && expr.followThreeValuedLogic && nativeLambdaSupported( | ||
| expr.argument, | ||
| body) => |
There was a problem hiding this 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.
There was a problem hiding this 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 3bb85cf.
| 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 | ||
| } | ||
| } |
There was a problem hiding this 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:
| 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 | |
| } | |
| } | |
| 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.
There was a problem hiding this 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 3bb85cf.
| 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) | ||
| })?; |
There was a problem hiding this 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:
| 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) | |
| })?; | |
| // `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) | |
| })?; |
There was a problem hiding this comment.
Done, it's one walk now, and your comment is folded into the existing one. Fixed in 3bb85cf.
| @@ -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 | | | |||
There was a problem hiding this 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.
There was a problem hiding this 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 3bb85cf.
| 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") | ||
| } |
There was a problem hiding this 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.
There was a problem hiding this 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 3bb85cf.
| // 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. |
There was a problem hiding this 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?
There was a problem hiding this 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 3bb85cf.
Spark's exists stops at the first element that matches, so it never evaluates the lambda on the elements after it, while array_any_match evaluates the lambda on every element. With ANSI on, exists(ints, x -> 100 / x > 1) over array(1, 0) raised DIVIDE_BY_ZERO natively where Spark returns true, and with ANSI off element_at with the index 0 raised in the same way. exists now also requires that no expression in the lambda can raise, whatever operators it uses. Also from review: - Reuse CometCast.canProduceNullMapKey for the map-cast check, so a widening key cast in the array argument runs natively. - Make CometArrayExists a plain serde that calls the dispatcher itself, like CometArrayFilter, so the generated docs say Native, and give its row the same notes as filter. - Collect the projection in one walk in NullShortCircuit's project(). - Drop tests that repeat the SQL files: the exists test in CometCodegenHOFSuite, the array_any_match half of the planner test and the numeric nvl query in array_filter_native_lambda.sql.
| !body.exists(_.isInstanceOf[HigherOrderFunction]) && | ||
| capturesOnlyFixedWidthColumns(body) && | ||
| shortCircuitSafe(body) && | ||
| !canProduceNullMapKey(argument) && |
There was a problem hiding this comment.
Check canProduceNullMapKey in the body too. For [1L, 9999999999L], array_contains(map_keys(try_cast(map_from_arrays(array(x), array(1)) AS MAP<INT,INT>)), 1) returns [1] through Spark's filter, but native casting aborts on a NULL key.
There was a problem hiding this comment.
You are right, and I reproduced it: with [1L, 9999999999L] Comet aborted on the NULL key where Spark returns [1]. The check now covers the body as well as the argument, for filter and exists. array_filter_native_lambda_unsupported_body.sql pins your query with expect_dispatch, next to the same shape with a widening key cast on the native path.
| // to a string. | ||
| CometArrayCompact.convert(expr, inputs, binding) | ||
| case LambdaFunction(body, Seq(lambdaVar: NamedLambdaVariable), _) | ||
| if binding && nativeLambdaSupported(expr.argument, body) => |
There was a problem hiding this comment.
filter(dates, x -> CAST(x AS STRING) <> '') now hits #7471 for date_from_unix_date(100000000): Spark succeeds, native formatting fails. Keep these bodies on the dispatcher until the kernel supports Spark's date range.
There was a problem hiding this comment.
Agreed. A body with a cast between a date or timestamp and a string, date or timestamp now stays on the dispatcher, until the cast supports Spark's year range (#7471). That covers CAST(x AS STRING) on your date_from_unix_date(100000000), and the casts between a date and a timestamp, which the issue shows failing too. The fixture pins them with expect_dispatch, and a timestamp to number cast stays native. The tuning page has a bullet for it.
…t on the dispatcher A body that holds a TRY cast making a null map key cannot be built natively, as an argument with one cannot (apache#6172), and a cast between a date or timestamp and a string, date or timestamp fails natively for years beyond chrono's range, which Spark supports (apache#7471). Both now stay on the dispatcher, with a fixture that pins the path and the results.
Which issue does this PR close?
Closes #7321.
Rationale for this change
filter(arr, x -> ...)ran natively only forx IS NOT NULL, andexists(arr, x -> ...)never did. Any other lambda went through the JVM codegen dispatcher, which evaluates it one element at a time like Spark does, plus the transfer of each batch, so such queries ran at about Spark's speed. DataFusion 55 evaluates a lambda once over all the elements of a batch, so Comet can use it when the body is made of natively supported expressions. This PR does that forfilter, witharray_filter, and forexists, witharray_any_match, which returns what Spark'sexistsreturns under three-valued logic.Release build, Spark 4.1, 1M rows of ten-element arrays (every fifth element is NULL), best time:
filter(strs, x -> nvl(x, '') != '')array_remove(array_compact(strs), '')filter(ints, x -> x IS NOT NULL)filter(ints, x -> x > threshold)existsover 1M rows of ten-element arrays (every fifth element is NULL), Spark 3.5, release build, best time:exists(ints, x -> x > 90)exists(dbls, x -> x IS NULL OR x < 0 OR x > 90 OR x = 0)exists(ints, x -> x > threshold)size(dbls) < 1 OR exists(dbls, x -> x > 90)exists(strs, x -> x IS NULL OR x = '')filter(ints, x -> nvl(x, '') != '')is not in the first table because it no longer takes the lambda path: Comet runs it asarray_compact(see the last change below). In a separate local run (Spark 3.5.5,local[4], 2M rows of ten-element int arrays with every fifth element NULL, best of 5) it took 0.453 s on the dispatcher, 0.281 s with the native lambda alone, and 0.086 s asarray_compact, against 0.220 s on Spark.What changes are included in this PR?
HigherOrderFuncand aLambdaVariableexpression in the protobuf, and a planner arm that builds DataFusion'sHigherOrderFunctionExprwith aLambdaExpr. The lambda body is planned against the input schema plus the lambda parameter, so captured columns keep their indexes.CometArrayFilterandCometArrayExistsshare the code that serializes a lambda (theNativeLambdaSerdetrait). They serialize the body natively when all of these hold: the lambda has one parameter, the array has a primitive, decimal, string, binary, date or timestamp element type, the body is deterministic, has no nested lambda and uses the default string collation, it reads no captured column that is not fixed-width, it holds no TRY cast that can make a null map key and no cast between a date or timestamp and a string, date or timestamp (the native cast fails beyond chrono's year range, Native timestamp casts fail or panic for years beyond chrono's range #7471), and every subexpression of it converts natively. Otherwise they keep using the dispatcher, as before. The planner arm mapsarray_filterandarray_any_matchto DataFusion's functions.existshas three more conditions.spark.sql.legacy.followThreeValuedLogicInArrayExistsmust be true (the default): with it false Spark returns false wherearray_any_matchreturns NULL, soexistsstays on the dispatcher. No expression in the body may raise, whatever operators it uses: Spark stops at the first element that matches and never evaluates the body on the elements after it, whilearray_any_matchevaluates it on every element, so with ANSI onexists(array(1, 0), x -> 100 / x > 1)would raise where Spark returns true. It is the same check as theAND/OR/INrule below. And the array argument must not contain a TRY cast whose map key cast can fail (CometCast.canProduceNullMapKey): it makes a null key, which Arrow cannot hold (Codegen dispatcher writes a null map key as the key type's default value #6172). The dispatcher runs such an argument in Spark's own code, where the map never reaches Arrow.filterstays on the dispatcher.filterbody that usesAND,ORorINstays on the dispatcher unless no expression in it can raise. Spark evaluates those lazily and the native operators evaluate every operand, sox != 0 AND 100 / x > 1would otherwise raise on the elements Spark skips. This is the per-row case in Track evaluation masks for data-dependent errors beyond unbase64 operator fallback #6006. The check classifies each expression and fails closed: arithmetic and casts raise only in the ANSI eval mode they carry, andelement_atraises for the index 0 in any mode, so an expression that is not known to be safe counts as able to raise.CASE,IFandCOALESCEmatch Spark natively and are not restricted.NullShortCircuitevaluates the later arguments of functions such asarray_containson a projection of just the columns they read. It now projects and re-indexes aLambdaVariablelike aColumn, whichfilter(ints, x -> array_contains(other, x))needs. A lambda inside the projected expression binds its own parameters, so those are not columns of the batch and are left alone. Without that, a higher-order function inside an expression that is evaluated for only some rows, such as the right operand ofANDorOR, or an argument ofarray_contains, failed withproject index N out of bounds.case_when.rstreats aLambdaVariablelike aColumnwhen it decides whether an expression can fail.filter(arr, x -> nvl(x, '') != '')on an array of integers, floats, decimals, dates or timestamps runs asarray_compact, the wayx IS NOT NULLdoes. Spark plans it asNOT (coalesce(CAST(x AS STRING), '') = ''), in either operand order. No such cast is empty, so the predicate holds exactly whenxis not NULL, and the native lambda would otherwise cast every element to a string first. A string element has no cast and stays on the lambda, as does any other default value, comparison or extra condition.spark.comet.exec.nativeLambda.enabled(default true) turns the native path off.nvl(x, '') != ''on a numeric array is handled. Thefilterandexistsrows of the Supported Expressions page link to it.CometArrayExistscalls the dispatcher itself, asCometArrayFilterdoes, so the generated Implementation column says Native for both.CometHigherOrderFunctionBenchmarkproduces the numbers above,filterandexists.Not included:
transformandforallstill use the dispatcher. DataFusion 55.2 hasarray_transformbut no all-match function.How are these changes tested?
array_filter_native_lambda.sqlruns 42 queries against Spark with ANSI mode off: every supported element type includingtinyintandsmallint, NULL, empty and all-NULL arrays and batches, literal arrays, NaN and -0.0, captured columns, a parameter that shadows a column, nested filters, and bodies that must stay on the dispatcher. Each query pins its path withexpect_native(filter)orexpect_dispatch(filter). It also has queries with a lambda inside an argument evaluated for only some rows (array_contains(ints, size(filter(ints, x -> x > 1)))) and as the right operand ofANDandOR.array_filter_native_lambda_ansi.sqlcovers the short-circuit queries under ANSI mode, andarray_filter_native_lambda_captured.sqlcovers bodies that read captured columns of each kind and array functions that take the lambda parameter, with ANSI on and off.array_filter_native_lambda_unsupported_body.sqlpins the bodies with a null-map-key cast or a date or timestamp cast to the dispatcher, forfilterandexists, with a widening map cast and a timestamp to number cast on the native path.array_filter_native_lambda_disabled.sqlchecks thatspark.comet.exec.nativeLambda.enabled=falseruns every lambda on the dispatcher.array_filter_numeric_nvl.sqlcovers thearray_compactrewrite: every numeric, date and timestamp element type, both operand orders,<>andcoalesce, NULL, empty and all-NULL arrays, NaN and -0.0, the result insidesizeandarray_max, and the string, other-default,=and extra-condition shapes that must keep their lambda semantics. Spark refusescoalesce(boolean, ''), so there is no boolean query.array_exists_native_lambda.sqlruns 19 queries against Spark with ANSI mode off, 15 of them pinned withexpect_native(exists): string, integer, double and float arrays, NULL, empty and all-NULL arrays, predicates that are NULL for some elements and false for the rest, captured columns,flattenas the argument, andexistsas the right operand ofORand inside an argument ofarray_contains. Two bodies can raise on an element Spark never reaches and are pinned withexpect_dispatch(exists):element_atwith the index 0, andtimestamp_secondsof a BIGINT, which raises on overflow. Two aggregate queries only compare with Spark, because the harness counts an expression inside an aggregate as dispatched as well as native.array_exists_native_lambda_ansi.sqlruns six queries under ANSI mode. Four bodies raise only on an element after the first match (a division, an addition, a cast of a string, and a division insideCASE) and are pinned withexpect_dispatch(exists). Two that cannot raise are pinned withexpect_native(exists). Without the condition onexists, the division query raisedDIVIDE_BY_ZEROnatively, and theelement_atquery in the non-ANSI file raisedINVALID_INDEX_OF_ZERO, where Spark returns true for both.array_exists_native_lambda_two_valued.sqlchecks thatexistsstays on the dispatcher with three-valued logic off.codegen_dispatch_null_map_key.sqlpins theexistswith a TRY map cast whose key cast can fail to the dispatcher, and the same query with a widening key cast to the native path.array_filterresult, a non-array argument, an unknown function),array_any_matchas the right operand ofORandAND, andNullShortCircuitwith a lambda variable in a masked argument. Without theNullShortCircuitchange, the last one fails withLambdaVariable references column … but batch only has 0 columns.run-spark-4.1-testslabel. Locally, the lambda,existsandcodegen_dispatchSQL files,CometCodegenHOFSuite,CometArrayExpressionSuiteandGenerateDocsSuitepass on Spark 4.1, and the SQL files andCometCodegenHOFSuitepass on Spark 3.5.