Repository navigation
fix: dispatch map lookups with normalized keys and nondeterministic null-guarded children - #5867
dwsmith1983 wants to merge 93 commits into
Conversation
ff812cc to
689c563
Compare
…ull-guarded children Map lookups with float, collated or complex keys were declined by the serde without a dispatch fallback, so the whole projection fell back to Spark. Four array and map serdes reproduce NULL propagation with a guard that serializes the child twice, so a stateful child was evaluated twice natively and returned wrong answers. Mix the codegen dispatch fallback into the map lookups, and decline any nondeterministic child in size, array_append, arrays_zip and map_from_arrays through one shared gate so Spark's generated code evaluates it once. Closes apache#5580 Closes apache#5781
689c563 to
dfbf000
Compare
andygrove
left a comment
There was a problem hiding this comment.
The comment above getSupportLevel says only the array operand sits under the NULL guard, but the item is inside the THEN branch too, and DataFusion's CaseExpr filters the batch before it evaluates THEN. So the item is evaluated only on the rows the guard selects.
That matters for array_append specifically, because it is the one expression in this group whose Spark codegen does not short-circuit. On 3.4 and 3.5 ArrayAppend.doGenCode emits leftGen.code + rightGen.code + ctx.nullSafeExec(left.nullable, leftGen.isNull) { ... }, so rightGen.code runs on every row even when the array is NULL. ElementAt and MapFromArrays go through nullSafeCodeGen and Size has one child, which is why the guard matches Spark for those three.
On your own 16 row table, SELECT _1, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), monotonically_increasing_id()) FROM test_array_append_nondet gives Spark [1,0], [1,2], [1,4] and so on for the even rows, because the counter advances on all 16. Comet evaluates the native monotonically_increasing_id only over the 8 filtered rows and gives [1,0], [1,1], [1,2] up to [1,7]. The same filtering also swallows an error the item would have raised. Under ANSI, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), 1 / (_1 - 1)) raises DIVIDE_BY_ZERO in Spark at _1 = 1 and returns cleanly in Comet.
The expect_native case below uses array(1), which is non-nullable, so the guard mask is all true and CaseExpr takes the branch that skips the filter entirely. That case passes whatever the serde does, which is why the fixture does not catch this. Would it make sense for NullGuardSupport to cover both children of ArrayAppend, and to change that fixture to a nullable operand with a stateful item so it fails without the change?
On sequencing, I have commented on #5875 pointing it here, since this PR carries the null-guard correctness work as well as the map lookup dispatch and that makes it the better base. Two things to settle before it lands though. #5875 measured this exact routing change and found lookup-only projections running 8.7 to 11.4 percent slower with the dispatcher on at 65536 rows, with no reproducible benefit at 1024 rows. Do you have numbers for the routing here? And #5854 teaches the native map builders LAST_WIN and null key rejection, which would remove the Incompatible branch this PR routes through the dispatcher for map_from_arrays, so that one needs an order agreed with @peterxcli.
Last thing is coverage for the routes this enables. map_from_arrays_dedup_policy.sql sets spark.comet.exec.scalaUDF.codegen.enabled=false and keeps asserting expect_fallback(mapKeyDedupPolicy), so the new LAST_WIN dispatch route is never exercised. Could those queries assert expect_dispatch(map_from_arrays) instead, with a separate dispatcher-off file if the fallback is still worth pinning? The four new nondeterministic fixtures also build every array inline over an int column, so no dispatched kernel in them reads an Arrow ListVector from a column. #5844 hit a real Spark 3.4.3 ColumnarArray.copy problem in exactly that shape, so an array<int> column in at least the size and map_from_arrays fixtures would be worth having. While you are in expressions.md, cardinality still says Native two rows above size, and Spark registers both names against Size.
…d-nondeterministic-children
…in the dispatched fixtures
…e guard filters its evaluation
Yes to both.
Done:
Added to both: the dispatched kernel now reads its arrays from columns as well as building them inline.
Not yet; I will run the same shape #5875 measured before this lands. On #5854, once native LAST_WIN lands the |
|
Triage note: #5875 also closes #5580, and mixes From reading both, this one looks like the superset: it also closes #5781 for the stateful child being serialized twice under the |
…aches the null guards
The nested null guards serialize each child a second time inside the
`map_from_arrays` call, so a stateful child advances independently in
each copy: the guard's copy sees every row while the constructor's copy
sees only the rows the guard selected. With
map_from_arrays(IF(monotonically_increasing_id() % 2 != 0, array(1), NULL), array(2))
over sixteen rows in one partition, Spark returns eight maps and Comet
returned four (apache#5781). Under LAST_WIN this case used to fall back for
the policy alone, so running the policy natively exposed it there.
Port `NullGuardSupport` from apache#5867 unchanged in name, reason and
position, so that PR rebases by dropping the hunk, and decline a
nondeterministic child in `CometMapFromArrays.getSupportLevel` as
`Unsupported`; the projection falls back to Spark, which evaluates the
child once. apache#5867 still routes the same decline through the JVM codegen
dispatcher and applies it to `size`, `array_append` and `arrays_zip`.
Cover it with the query above as a Scala test on a one-partition table
under LAST_WIN, the same query in `map_from_arrays_dedup_policy.sql`,
and `map_from_arrays_nondeterministic_child.sql` for the default policy,
which mirrors the fixture in apache#5867 with `expect_fallback` in place of
`expect_dispatch`.
Yes, happy for this PR to carry the map-lookup serde. The |
…profile The strict profile now warns on unused parameters. The default dispatchDeclineReason ignores its expression, as main's getSupportLevel defaults do, so it gets the same scoped -Wconf filter in both argument lists.
…d-nondeterministic-children
|
The |
…d-nondeterministic-children
…d-nondeterministic-children
sunchao
left a comment
There was a problem hiding this comment.
Reviewed commit d1302c055d26497a81f92d16ebbc2d47cbdea3bb. New P1/P2 findings are detailed inline.
| } | ||
|
|
||
| object CometElementAt extends CometExpressionSerde[ElementAt] { | ||
| object CometElementAt extends CometExpressionSerde[ElementAt] with CodegenDispatchFallback { |
There was a problem hiding this comment.
[P2] [P2] Preserve shared-expression evaluation when dispatching map lookups. With ANSI and spark.sql.subexpressionElimination.enabled=true, a Parquet table containing nullable DOUBLE-key maps m1, m2 and x = 0 makes SELECT element_at(m1, 1.0D/x), element_at(m2, 1.0D/x) FROM t raise DIVIDE_BY_ZERO in Spark even when both maps are NULL. Spark’s ProjectExec evaluates the shared division before the lookup NULL guards. This newly enabled route serializes each lookup separately, so both generated kernels skip the division and return NULL, allowing the query to succeed instead. At the base, DOUBLE-key lookups fall back to Spark. Could this route preserve eager shared-expression evaluation, or retain Spark fallback for this shape? canShortCircuitNulls already identifies the constraint, but dispatch bypasses it.
Evidence: Reproduced with freshly compiled head serializers/planner/dispatcher on Spark 4.1.3 in /tmp/review5867-d1302-current-_xjobhhq/SharedDeterministicProbe.scala, with results in SharedDeterministicProbe.log. Input equivalent: CREATE TABLE t(m1 MAP<DOUBLE,INT>, m2 MAP<DOUBLE,INT>, x DOUBLE) USING parquet; INSERT INTO t VALUES (NULL, NULL, 0.0D);. Spark raises DIVIDE_BY_ZERO for the two-lookup query. Either lookup alone returns NULL, and disabling subexpression elimination makes the pair return [NULL,NULL]. With elimination enabled, the exact-head planner reports canShortCircuitNulls=false yet emits both JVM dispatch expressions. Evaluating those serialized expressions returns NULL for each column. Spark’s ProjectExec.doConsume confirms eager common-expression evaluation. This is a component reproduction, not a full native execution run.
There was a problem hiding this comment.
Could this route preserve eager shared-expression evaluation, or retain Spark fallback for this shape?
It keeps Spark fallback for it. QueryPlanSerde.skipsSharedSubexpression looks, inside the operator's withSharedSubexpressions scope and with subexpression elimination on, for a node in the expression that can skip an argument holding a subexpression the operator shares. For most nodes that is any argument after the first. Division and pmod evaluate the divisor first and skip the dividend, and locate skips its first two arguments when start is NULL. The six serdes this PR newly dispatches decline in that case, so element_at(m1, 1.0D/x), element_at(m2, 1.0D/x) falls back to Spark as it did at the base. A subexpression the expression repeats on its own stays dispatched, since the kernel eliminates it too and evaluates it for every row.
Declining exposed two plan-time checks that ran outside that scope while serialization ran inside it: the native shuffle key check and the TakeOrderedAndProject projection check. Without a fix, DISTRIBUTE BY or a sort on such a pair would have chosen native shuffle and then failed at execution. Both now use the scope the writer and finalNativePlan use.
The native element_at and map_from_arrays guards have the same gap on main with INT keys, which is #6970, and the other dispatch serdes on main have it as well, which is #6971.
There was a problem hiding this comment.
One change since: the check no longer looks at spark.sql.subexpressionElimination.enabled. Spark's whole-stage codegen aggregates share the subexpressions of their update expressions even with it off, so max(element_at(m1, 1.0D/x)), max(element_at(m2, 1.0D/x)) still raises there, and the dispatched lookups would not have. These shapes now fall back whatever the setting says.
…d-nondeterministic-children
…pression With subexpression elimination on, Spark's projection evaluates a subexpression that several of its expressions share for every row, before their NULL checks. The codegen dispatcher compiles each expression alone, so a node that skips an argument on a NULL input never evaluates the shared subexpression there, and an ANSI error Spark raises goes unraised. The six serdes this branch newly dispatches now decline when some node in the tree can skip an argument holding an operator-shared subexpression, and fall back to Spark as before. Division, pmod and locate evaluate a later argument first, and Spark 3.x array_append evaluates both, so the check follows their order. The native shuffle key checks and the TakeOrderedAndProject projection check now run in the same shared-subexpression scope as the code that serializes those expressions at execution, so a decline there falls back at planning instead of failing at execution.
Spark's whole-stage codegen aggregates share the subexpressions of their update expressions even with spark.sql.subexpressionElimination.enabled off, so an aggregate over two dispatched lookups that share a raising key still raises in Spark. The decline no longer looks at the setting, so those shapes fall back. A subexpression the expression repeats on its own still stays dispatched, but only while the setting is on, since the kernel eliminates repeats only then.
…d-nondeterministic-children
…d-nondeterministic-children
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the work on this. I went through the current head again, including the calendar-interval declines and the shared-subexpression check, and compared the touched serdes against branch-1.1. The routing changes look right to me, and the fixtures fail without the declines.
One thing is still open, in CometArrayAppend.getSupportLevel (arrays.scala). The SQLConf.get.ansiEnabled && expr.left.nullable gate misses in both directions.
With ANSI off, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), element_at(array(1), _1 - 1)) should raise INVALID_INDEX_OF_ZERO in Spark 3.4 and 3.5. element_at raises on index 0 whatever the ANSI setting, and ArrayAppend.doGenCode evaluates the item on every row. The native CASE only evaluates the item where the array is not NULL, so Comet returns a value. With ANSI on, a plain column item is dispatched even though it can't raise. I did not rerun this at the current head, so please correct me if I have misread the gate.
Would it make sense to gate on the item instead of on the ANSI setting? CometArrayJoin.orderInsensitive in this file checks for a literal or a column, and that is the case that is safe to keep native. Could array_append_ansi_null_array.sql also gain a case with ANSI off?
…d-nondeterministic-children
The native NULL guard skips the item on rows whose array is NULL, while Spark's generated code evaluates it on every row, so an item such as element_at(array(1), _1 - 1) raises in Spark with ANSI off too. A nullable array with an item other than a literal or column now runs through the codegen dispatcher in both modes, and a column or literal item stays native under ANSI. The literal-or-column check that array_contains and array_join each carried is shared as EvaluationOrder.isUnobservable.
Yes, you read the gate right, and it is changed in 4fde11b. The ANSI-off cases are in a new |
…d-nondeterministic-children
…d-nondeterministic-children
…ees bind alike Two arrays_zip calls over different columns bind to the same ordinals, so without a per-occurrence tag they shared one kernel and the second monotonically_increasing_id carried on from the first.
…d-nondeterministic-children
…d-nondeterministic-children # Conflicts: # docs/source/user-guide/latest/expressions.md # spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala # spark/src/main/scala/org/apache/comet/serde/maps.scala # spark/src/test/resources/sql-tests/expressions/map/element_at_map.sql # spark/src/test/resources/sql-tests/expressions/map/get_map_value.sql # spark/src/test/resources/sql-tests/expressions/map/map_from_arrays_dedup_policy.sql # spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala
…d-nondeterministic-children
…d-nondeterministic-children
Which issue does this PR close?
Closes #5580, closes #5781.
Rationale for this change
Two serde gaps in the array and map expressions, both resolved by the codegen dispatcher rather than by native changes.
map_col[key]andelement_at(map, key)decline float, collated and complex map keys because the native lookup compares raw Arrow values where Spark finds a0.0key with-0.0, treats NaN as equal to itself, compares strings by collation and compares complex keys with interpreted ordering. The declines are right, but neither serde mixed inCodegenDispatchFallback, so the whole projection fell back to Spark instead of running Spark's own generated code inside the Comet pipeline.size,array_append,arrays_zipandmap_from_arraysreproduce Spark's NULL propagation with aCASE WHEN child IS NOT NULLguard that serializes the child twice. A stateful child advances each copy independently, so the guard and the operation see different rows and the answer is silently wrong: on a 16-row table withIF(monotonically_increasing_id() % 2 = 0, array(1), NULL)as the operand,sizereturned -1 on five rows where Spark returns 1,arrays_zipreturned[null, 2]for[1, 2],array_appendreturned[2]for[1, 2]andmap_from_arraysreturned NULL for{1 -> 2}.element_athad the same shape and was fixed in #5766 for its ANSI arm; these four are not ANSI-gated, so the wrong answers were reachable in every configuration.What changes are included in this PR?
CometMapExtractandCometElementAtmix inCodegenDispatchFallback, so the declined key types run through the dispatcher.CometElementAt's ANSI arm for a nondeterministic operand dispatches the same way.NullGuardSupportgate declines any nondeterministic child inCometSize,CometArrayAppend(the array and the item, which sits inside the guard'sTHENbranch),CometArraysZipandCometMapFromArrays, and all four mix inCodegenDispatchFallback, so Spark's generated code evaluates the child once. Nullability is not consulted: a non-nullable stateful child only stays correct today because DataFusion skips the filter when the guard matches every row, which is not a contract to rely on.CometArrayAppendreports a nullable array with an item other than a literal or column asIncompatible, whatever the ANSI setting, so that shape runs through the dispatcher by default. The native guard skips the item on rows whose array is NULL, and an item such aselement_at(array(1), _1 - 1)raises there in Spark with ANSI off too. A literal or column item cannot raise and stays native. The literal-or-column check is the oneCometArrayContainsandCometArrayJoinalready used, now shared asEvaluationOrder.isUnobservable.QueryPlanSerde.skipsSharedSubexpressionfinds a node in the tree that can skip an argument holding such a subexpression: any argument after the first, the dividend of a division orpmod(the divisor is evaluated first), the first two arguments oflocate(startis evaluated first), and none for Spark 3.xarray_append, which evaluates both. The six serdes above decline dispatch in that case and fall back to Spark, as before this change. A subexpression the expression repeats on its own does not count whilespark.sql.subexpressionElimination.enabledis on, since the kernel then eliminates it as well. With the setting off, only an aggregate counts its shared subexpressions, likecanShortCircuitNulls, because Spark's whole-stage codegen aggregates share the subexpressions of their update expressions even then. A projection shares nothing, so its calls stay dispatched.CometShuffleExchangeExecand the projection check inCometTakeOrderedAndProjectExec.getSupportLevelnow run in the samewithSharedSubexpressionsscope thatCometNativeShuffleWriterandfinalNativePlanuse when they serialize. Before, a decline that only fires inside that scope would let planning choose the native path and then fail at execution. The columnar shuffle checks are unchanged, since that path evaluates its keys with Spark's own projection.getUnsupportedReasonslists updated for the doc generator, and the six affected rows in the expressions guide move from Native to Hybrid, and thearray_appendrow says which items are dispatched.How are these changes tested?
SQL-file fixtures over parquet tables, all asserting Spark's answer and the execution path:
Four new
*_nondeterministic_child.sqlfixtures, one per serde, with the stateful operand (dispatched), a non-nullable stateful operand (dispatched), and a deterministic nullable operand (native).array_appendalso pins that a stateful item is dispatched, and its fixture is capped at Spark 3.5 because 4.x rewritesarray_appendtoarray_insert. Before the change, the stateful cases failed as result mismatches with the values above.element_at_map.sqlandget_map_value.sqlflip their fallback cases to dispatch and add NaN lookups and a struct-keyed map column with a per-row key, a NULL inside the key and a NULL key.element_at_map_collation.sqlflips the same way and passes on the Spark 4.0 profile.element_at_ansi.sqlpins the dispatched nondeterministic arm.codegen_dispatch_calendar_interval_output.sqlpins thatmap_from_arraysdeclines the dispatcher for a calendar-interval result on its float-key and LAST_WIN routes.map_builders_float_key_gate.sql,map_builders_float_key_gate_legacy.sqlandmap_builders_float_key_last_win.sqlgive the FLOAT call its own values array (array(3, 4)). The SQL test harness turns offConstantFolding, so anarray(1, 2)shared by both calls counts as a shared subexpression and declines the dispatch; in a real query it folds to a literal.CometMapExpressionSuiterenames five fallback tests to dispatch tests through the helper that checks the dispatch tag.array_append_ansi_null_array.sql(ANSI on) andarray_append_null_array_item.sql(ANSI off) pin that an item raising on a NULL-array row raises through the dispatcher as in Spark (1 / (_1 - 1)under ANSI,element_at(array(1), _1 - 1)without it), that a column or literal item over a nullable array stays native (the ANSI file adds a NULL literal), that any other item over a nullable array is dispatched, and that a non-nullable array stays native and raises on Comet when the item raises. The ANSI-off file also pairs two calls that shareelement_at(array(1), _1 - 1)inside map lookups, which fall back to Spark and raise. Thearrays_zipfixture adds a dispatched query over anarray<int>column so the kernel copies list vectors from real input.Six
*_shared_subexpression.sqlfixtures, with ANSI and subexpression elimination on, pair two expressions that share1.0D / xover a row with NULL input andx = 0, so Spark raisesDIVIDE_BY_ZERO. They cover each of the six serdes, a division and apmodin the tree,locatewith a NULLstart, an aggregate, and the operator paths (a hash shuffle, a global sort, andTakeOrderedAndProject). Controls pin that a single call, a column key, a subexpression shared only in the first argument and one the expression repeats itself all stay dispatched. With subexpression elimination off, an aggregate pair still raises like Spark and a projection pair stays dispatched and returns rows like Spark. Each decline, evaluation-order rule and planner scope was removed in turn to confirm a fixture fails without it.Run on Spark 4.1 (all map and array SQL fixtures,
CometMapExpressionSuite,CometNativeShuffleSuite,CometTopKSuite), Spark 3.5 (the shared subexpression and array SQL fixtures,CometArrayExpressionSuiteandCometMapExpressionSuite), Spark 4.0 (the shared subexpression fixtures andCometMapExpressionSuite), plus the strict-warnings compile.Performance
#5875 measured this same routing with
CometCodegenDispatchBenchmark. At 65,536 rows, DOUBLE-key lookup-only projections ran 8.7 to 11.4 percent slower through the dispatcher than with it switched off, and mixed projections with a DOUBLE key and a small map ran 10.5 to 12.7 percent faster. The slower lookup-only case is accepted here in exchange for keeping the whole projection in Comet, where before it fell back to Spark.What this does not cover
array_appendkeeps its item inside the native NULL guard'sTHENbranch, which DataFusion evaluates only on the rows where the array is not NULL, while Spark's codegen evaluates the item on every row. A nondeterministic item is declined to the dispatcher for that reason. A deterministic item that raises on a row whose array is NULL, such aselement_atwith index 0 or a division by zero under ANSI, would return cleanly on the native path where Spark raises. The serde therefore reports a nullable array with an item other than a literal or column asIncompatible, soarray_appendruns through the codegen dispatcher by default and raises like Spark, and the native path stays available underallowIncompatiblewith that divergence recorded in the compatibility guide. The check looks at the item's shape, not at whether it can raise, so an arithmetic item, or a column the analyzer wrapped in a widening cast (array_append(bigint_arr, int_col)), is dispatched as well. Evaluating the item outside the guard, so those shapes can run natively again, is tracked in #6086.The native
element_atandmap_from_arraysguards skip a shared subexpression the same way on main, tracked in #6970. The other dispatch serdes on main have the gap this PR closes for its six, tracked in #6971.map_from_entriesdoes not yet decline the dispatcher for a calendar-interval result, tracked in #7083.