Repository navigation
Conversation
…mode Spark's generated code evaluates the second operand of + - * only where the first is not NULL, the dividend of / div % only where the divisor is not NULL, and each concat argument only where every earlier one is not NULL. Comet evaluated every operand on every row, so an ANSI cast of a malformed string on a row that Spark returns NULL for failed the query. Wrap these expressions in NullShortCircuit, extended with an evaluation order so the divisor is evaluated first. A deterministic masked argument is first evaluated over the whole batch and only falls back to the selected rows if that fails, which keeps the common case as fast as eager evaluation. Part of apache#7246 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7fbb6474-7dd6-4c0e-bb27-bd78a480ca36
|
This changes the serde and native arithmetic and |
comphead
left a comment
There was a problem hiding this comment.
Thanks for picking this up. Reusing NullShortCircuit with an evaluation order keeps the change small. The order matches Spark 3.4.3, 3.5.8, 4.0.1 and 4.1.1: BinaryArithmetic.doGenCode uses nullSafeCodeGen for every numeric type including decimals, DivModLike evaluates the divisor first in both eval and doGenCode, and Concat.doGenCode stops at the first NULL.
Comparisons have the same shape. EqualTo and the ordering comparisons use defineCodeGen, so Spark evaluates the right side only where the left is not NULL. I expect WHERE n = CAST(txt AS INT) under ANSI to fail in Comet the way n + CAST(txt AS INT) did, but I have not run it. Does that belong under #7246, or in its own issue?
| if nulls.is_some() && self.speculate.load(Ordering::Relaxed) { | ||
| if let Ok(value) = evaluate_projected(projection, projected, batch, None) { | ||
| return Ok(value); | ||
| } | ||
| self.speculate.store(false, Ordering::Relaxed); | ||
| } |
There was a problem hiding this comment.
LazyLogical only speculates when more than half of the rows need the right operand (pending_rows * 2 > num_rows in lazy_logical.rs). Here any NULL is enough, so when nearly every row is skipped the masked argument runs over the whole batch. That also applies to the existing users from #6716, such as array_contains, slice, make_date and GetMapValue, which on main evaluate an argument like CAST(s AS INT) over only the rows that need it. Could this use the same rule, for example nulls.null_count() * 2 < batch.num_rows(), and could the benchmark add a point at 90% NULL? I have not measured it.
The struct doc also still says the function sees NULL for each skipped argument. With speculation it sees the computed value there instead, so the function must also not fail on a row where an earlier argument is NULL, whatever the later ones hold. SparkArraySlice and SparkMakeDate check for NULL first, so I do not see a current bug, but could the doc here and the withNullShortCircuit Scaladoc say so, so that a new kernel does not validate first?
There was a problem hiding this comment.
Thanks, measured it before picking a cutoff. The benchmark now also runs at 70% and 90% NULL, has a decimal shape, and I ran it on a GitHub runner always speculating and never speculating (run, from the measurement-only branch https://github.com/tlfua/datafusion-comet/tree/bench/7246, which is not part of this PR). The table is in the PR description.
Where selecting the rows starts to win depends on how much the argument costs. For a + CAST(s AS BIGINT) it is already faster at 50% NULL (65 vs 77 µs). For a * (b - c) it only wins at 90% (7.9 vs 8.6 µs), and at 50% it is 1.8x slower (20.2 vs 11.2 µs). So null_count * 2 < num_rows would cost the cheap arithmetic 1.8x at exactly half NULL.
I went with null_count * 2 <= num_rows, the same split as LazyLogical. For the three shapes it is at most 1.2x off the better choice up to half NULL and 1.4x above it, and at 90% NULL it is 4.5x faster than always speculating for the cast. A batch above the cutoff selects the rows without turning speculation off, so later batches still speculate. That also keeps the existing NullShortCircuit users from #6716 on the old path for mostly-NULL batches.
The doc now says the function must return NULL wherever an earlier argument is NULL without checking the later ones, both on NullShortCircuit and on withNullShortCircuit.
| let expr = if options.null_short_circuit { | ||
| // Spark evaluates the divisor of a division first | ||
| let order: &[usize] = if op == DataFusionOperator::Divide { | ||
| &[1, 0] | ||
| } else { | ||
| &[0, 1] | ||
| }; | ||
| NullShortCircuit::wrap_in_order(expr, order, &input_schema)? | ||
| } else { | ||
| expr | ||
| }; |
There was a problem hiding this comment.
I think two things change for decimal operands once this wrapper is in the tree. I have not run it, this is from reading the code.
DecimalPrecision.promoteputs every decimal+,-,*,/and%underCheckOverflow. TheCheckOverflowarm skips the check for aWideDecimalBinaryExpr(perf: Optimize some decimal expressions #3619), butunwrap_checkedonly looks throughCheckedBinaryExpr. ForCheckedBinaryExpr(NullShortCircuit(WideDecimalBinaryExpr)),is_wide_decimalis now false and the extraCheckOverflowpass comes back.CAST(n AS DECIMAL(38, 0)) + CAST(txt AS DECIMAL(38, 0))in the new fixture takes that path.- A decimal operand reaches
NullShortCircuitas aCheckOverflow, which is neither incannot_failnor inis_deterministic. Soprice * (1 - discount)is wrapped even in LEGACY mode, where Spark returns NULL on overflow rather than failing, and speculation never applies to it. A batch with NULL prices then takes the selection path that the description measured at up to 2x forBIGINT.
Could unwrap_checked look through NullShortCircuit, and is_deterministic accept CheckOverflow? A decimal shape in the new benchmark would show whether that is enough.
There was a problem hiding this comment.
You're right on both, thanks for reading it that closely.
unwrap_checkednow also looks throughNullShortCircuit, so theCheckOverflowover a wide decimal add, subtract or multiply is dropped again.test_check_overflow_skipped_over_short_circuited_wide_decimalinplanner.rsplansCheckOverflow(a + CAST(s AS DECIMAL(38, 0)))under ANSI and checks that the result is the wrappedWideDecimalBinaryExprwith noCheckOverflow, and that it returns NULL for the row whereais NULL andsis malformed.is_deterministicnow acceptsCheckOverflow, and aNullShortCircuitwhose inner expression is deterministic, so decimal and nested operands speculate.decimal_and_nested_short_circuited_arguments_are_speculatedcovers both, and the benchmark has a decimal shape now.
I did not add decimal arithmetic or CheckOverflow to cannot_fail, because I can't show that the decimal kernels never return an error. So a LEGACY decimal operand is still wrapped, but with speculation it costs about the same as unwrapped (decimal rows in the table).
| statement | ||
| INSERT INTO filled SELECT /*+ REPARTITION(1) */ * FROM VALUES (1, 1, 'bad'), (2, 2, '2') AS t(id, n, txt) | ||
|
|
||
| -- Add, subtract and multiply evaluate the right operand only where the left one is not NULL |
There was a problem hiding this comment.
Could this file also cover the other paths the planner now wraps?
TINYINTandSMALLINT, which use thechecked_*kernels onInt8andInt16DOUBLEfor+,-and*, which stays a plainBinaryExprtry_addandtry_divide, which Spark plans asEvalMode.TRYarithmetic- a zero divisor under ANSI. Spark returns NULL for a NULL dividend over
0, and forCAST(txt AS INT) DIV nwithtxt = 'bad'andn = 0it raisesCAST_INVALID_INPUTrather thanDIVIDE_BY_ZERO, becauseDivModLikeevaluates the dividend before its zero check.
null_short_circuit_skips_the_dividend_where_the_divisor_is_null in modulo_expr.rs repeats the DECIMAL(38, 10) % DECIMAL(38, 0) query in this file, so it could go.
There was a problem hiding this comment.
Added all of them:
TINYINT+and%,SMALLINT*andDIVDOUBLE+,-and*try_addandtry_divideover skipped rows, andtry_addwith a malformed operand that Spark does evaluate, which still raisesCAST_INVALID_INPUT- a zero divisor:
DIV,%and/return NULL for a NULL dividend over0, andCAST(txt AS INT) DIV nwithtxt = 'bad',n = 0raisesCAST_INVALID_INPUT
Comet matches Spark on all of them. I also removed the duplicate test in modulo_expr.rs.
|
|
||
| /// Like [`create_modulo_expr`], but with `null_short_circuit` evaluates the dividend only for the | ||
| /// rows where the divisor, which Spark evaluates first, is not NULL. See [`NullShortCircuit`]. | ||
| pub fn create_modulo_expr_with_null_short_circuit( |
There was a problem hiding this comment.
After this change RemainderBuilder calls create_modulo_expr_with_null_short_circuit, so create_modulo_expr has no caller left outside its tests. The PR removes create_binary_expr for the same reason. Could create_modulo_expr take the null_short_circuit flag itself, so the crate keeps one public function instead of two?
There was a problem hiding this comment.
Done. create_modulo_expr now takes null_short_circuit: bool, create_modulo_expr_with_null_short_circuit is gone, and the tests pass false.
- Speculate a masked argument only when at least half the rows need it, chosen from benchmarks at 0-90% NULL across three operand shapes. - Look through NullShortCircuit in unwrap_checked so the redundant CheckOverflow over wide decimal arithmetic is still dropped, and treat CheckOverflow and nested NullShortCircuit as deterministic so decimal and nested operands speculate. - Merge create_modulo_expr_with_null_short_circuit into create_modulo_expr. - Document that a wrapped function must return NULL where an earlier argument is NULL without checking the later ones. - Cover tinyint, smallint, double, try_add, try_divide and a zero divisor in the SQL tests, and drop the duplicate modulo unit test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7fbb6474-7dd6-4c0e-bb27-bd78a480ca36
|
Thanks for the review. I've pushed dc1559c for the comments above. The PR description has the updated benchmark table and test list. On comparisons, you're right. |
|
Fork CI is green on dc1559c (run). Since this round also changes |
coderfender
left a comment
There was a problem hiding this comment.
Thanks for the careful work here, and especially for the benchmark table. I went through Spark's BinaryExpression.nullSafeCodeGen, DivModLike and Concat.doGenCode in 3.4, 3.5, 4.0 and 4.1, and the order you evaluate operands in matches. Operands Spark does evaluate still fail, in both directions. I also like that the zero divisor case outside ANSI falls out of the existing nullIfWhenPrimitive and null_if_zero_primitive rewrites.
A few things I'd like to settle before merge.
-
native/spark-expr/src/conditional_funcs/null_short_circuit.rs:333. Compared with the release, which evaluates eagerly, your table showsa * (b - c)under ANSI going from 9.5 to 14.0 µs at 70% NULL, and it stays slower until about 85%. That shape is common under ANSI, and eager evaluation there only goes wrong if the subtraction actually fails on a skipped row. Could the select-rows path be kept for arguments where it pays off, such as ones containing a parsingCastor a string function? Arguments built only from columns, literals and checked arithmetic would then always speculate first. If you'd rather keep a single threshold, could you say in the PR why the slowdown in that band is acceptable? -
native/spark-expr/benches/null_short_circuit.rs:162. The bench already producesnulls_0, but the table leaves it out. Could you add it? No NULLs is the common case. TPC-H Q1'sl_extendedprice * (1 - l_discount)now goes through the wrapper even in LEGACY mode, because its right side is aCheckOverflowandcannot_faildoesn't accept it. Could you also add a concat shape such asconcat(s, upper(t))?cannot_failrejects everyScalarFunctionExpr, so after this PR any concat whose later argument is a string function is wrapped in every eval mode. -
spark/src/test/resources/sql-tests/expressions/math/arithmetic_ansi_skipped_operands.sql:170. Outside a failing eval mode,DivModLikealso skips the dividend when the divisor is zero. Could you addSELECT id, try_divide(CAST(txt AS INT), n) FROM zero_bad, and a decimal variant, to pin that? Spark returns NULL there and the release raisesCAST_INVALID_INPUT. A table where more than half the rows are NULL would also run the path that selects rows without speculating end to end. Right nowskippedis exactly half NULL, so it always speculates first. -
Backport. The description says
branch-1.2has the same bug. Could we either backport a version without speculation (1.2 hasNullShortCircuitbut not #7326), or file a tracking issue for it asbackporting.mdasks for a correctness fix?
This changes the serde, the planner and native expressions, so I'll make sure the Spark SQL and benchmark checks run before it goes in the queue.
| // Its children are those of the function it wraps | ||
| || expr | ||
| .downcast_ref::<NullShortCircuit>() | ||
| .is_some_and(|n| is_deterministic(n.inner())); |
There was a problem hiding this comment.
Nested ANSI arithmetic doubles determinism work at each wrapper: n.inner() and the final all visit the same children. My 18-level helper probe counted 1,310,716 visits. Return the inner result directly.
There was a problem hiding this comment.
Good catch, thanks. is_deterministic now handles a NullShortCircuit first and returns is_deterministic(inner), without visiting the children again, so the check is linear in the size of the tree. deeply_nested_short_circuits_are_checked_in_linear_time in lazy_logical.rs nests 64 wrappers, which would take 2^64 steps with the old code. Fixed in 9b6c7be.
Address round-3 review of apache#7456: - Speculate a masked argument over the whole batch regardless of the NULL fraction when it is cheap (columns, literals, integer and float +, -, *, ANSI checked_add/sub/mul, widening casts), so `a * (b - c)` is no slower than before the fix on mostly NULL batches. Costlier arguments keep the half threshold. - is_deterministic no longer visits the children of a NullShortCircuit twice, which was exponential in the nesting depth. - Benchmark LEGACY TPC-H Q1, wide decimal, rescaling decimal cast and concat(q, upper(s)) shapes. - SQL tests for try_divide with a zero divisor and for mostly NULL batches with cheap and costly operands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7fbb6474-7dd6-4c0e-bb27-bd78a480ca36
Address round-3 review of apache#7456: - Speculate a masked argument over the whole batch regardless of the NULL fraction when it is cheap (columns, literals, integer and float +, -, *, ANSI checked_add/sub/mul, widening casts), so `a * (b - c)` is no slower than before the fix on mostly NULL batches. Costlier arguments keep the half threshold. - is_deterministic no longer visits the children of a NullShortCircuit twice, which was exponential in the nesting depth. - Benchmark LEGACY TPC-H Q1, wide decimal, rescaling decimal cast and concat(q, upper(s)) shapes. - SQL tests for try_divide with a zero divisor and for mostly NULL batches with cheap and costly operands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7fbb6474-7dd6-4c0e-bb27-bd78a480ca36
|
Thanks @coderfender, all four are addressed in 9b6c7be. The full table is in the updated description (bench run).
|
9b6c7be to
c818be3
Compare
| } else { | ||
| &[0, 1] | ||
| }; | ||
| NullShortCircuit::wrap_in_order(expr, order, &input_schema)? |
There was a problem hiding this comment.
a DIV b over ints, and d + a with an int a, get wrapped even in LEGACY because Cast::is_infallible excludes exact casts such as CAST(a AS DECIMAL(19, 0)). Could integer-to-decimal casts with enough digits count as infallible?
There was a problem hiding this comment.
Done in b948e2a. Cast::cannot_fail now accepts a cast from TINYINT, SMALLINT, INT or BIGINT to a DECIMAL(p, s) with s >= 0 and at least 3, 5, 10 or 19 integral digits (p - s), which holds every value of the integer. So the CAST(a AS DECIMAL(19, 0)) operands of a DIV b and of d + a are no longer wrapped. test_cannot_fail_casts_succeed_on_extreme_values_in_every_mode in cast.rs casts the minimum and maximum of each type in every eval mode, and test_casts_that_can_fail_are_not_claimed checks that one digit fewer is not accepted.
I put it in cannot_fail rather than is_infallible, because is_infallible also decides which CASE WHEN branches are evaluated over the whole batch up front, and these casts cost more per row than the ones it holds.
| expr.evalMode, | ||
| (builder, mathExpr) => builder.setAdd(mathExpr)) | ||
| (builder, mathExpr) => builder.setAdd(mathExpr), | ||
| skippable = Some(expr.right)) |
There was a problem hiding this comment.
Every LEGACY integer +/*, even with two operands, takes the balanced branch without the flag, so n + element_at(a, 0) fails where n is NULL. Could each balanced node set it from its right slice, matching Spark's left-deep chain?
There was a problem hiding this comment.
Good catch, thanks. Each balanced node now sets the flag from its right slice: createBalancedMathExpr keeps each operand next to its proto and calls canSkipEvaluation on the operands of the right half. A node skips its right half where its left half is NULL, which is where some earlier operand is NULL, so every operand is evaluated on the same rows as in Spark's left-deep chain. The new math/arithmetic_legacy_skipped_operands.sql runs chains of 2 to 5 operands under LEGACY, including one that starts with a BIGINT cast, whose last operand element_at(arr, i) fails on the rows Spark skips, and checks that it still raises INVALID_INDEX_OF_ZERO where Spark evaluates it, and where it comes first.
coderfender
left a comment
There was a problem hiding this comment.
Thanks for working through all of this so quickly. I checked the new head against my four points. is_cheap is what I had in mind, and I like that it only changes when speculation is tried, so a failure on a skipped row still falls back to selecting rows. The numbers in the description match the table, and the nulls_0 column shows no real overhead. The zero_bad and mostly_null queries pin what I asked for, and #7542 tracks the backport. Your reply cites 9b6c7be, whose tree is identical to c818be3, so the fork run covers this head.
There are two things I'd like to settle before merge, plus two simplifications.
-
native/spark-expr/src/conditional_funcs/null_short_circuit.rs:336. rich7420's fix makesis_deterministiclinear, but I think the same doubling is still inproject.Arg::try_newprojects its masked argument right away, andprojectrewrites column indices withtransform_down, which callswith_new_childrenon every node above a changed column. For a nestedNullShortCircuitthat isSelf::try_newagain, which projects its own argument again. So projecting one level rebuilds the level below it twice, once on the way down and once inside the rebuilt wrapper, which comes to 2^k - 1 calls toprojectfor k levels. It needs a projection that isn't the identity at each level, which is the normal case forc0 * (c1 * (c2 * ... (c19 - CAST(s AS INT))))under ANSI, and nested decimal arithmetic hits it in any mode since itsCheckOverflowoperand is masked. I traced this through the code rather than running it, so could you check?deeply_nested_short_circuits_are_checked_in_linear_timewouldn't see it, because with onlyvandsin the schema every projection is the identity. A version that reads a different column at each level, with the outer ones first, should show it. Building the projection lazily on the firstevaluate, for example in aOnceLock, looks like it would fix it and still give each argument one copy of its state across batches. -
spark/src/main/scala/org/apache/comet/serde/strings.scala:400. Thanks for measuring theconcatshape. It is still 1.4x slower than the release at 50% NULL and 1.2x at 70%, and sinceuppercan't fail, that cost buys nothing in any eval mode. I think it reaches further thanupper.Cast::cannot_faildoesn't accept a cast to a string, soconcat(name, CAST(id AS STRING))is wrapped too, and that cast isn't on theis_cheaplist either, so at half NULL or more it selects string rows. The new concat fixture uses that shape in every query. CouldCast::cannot_failaccept the casts toSTRINGthat can't fail, such as from integers, booleans and decimals, after checking each against the native cast, so those calls aren't wrapped at all? Aconcat(q, CAST(a AS STRING))shape in the bench would show the difference. For the string functions, I'd take you up on adding the ones you measured tois_cheaphere, or please file the follow-up as an issue so it doesn't get lost. -
null_short_circuit.rs:353.Arg::evaluateandLazyLogical::evaluatenow run the same steps. They try the whole batch whilespeculateholds, drop the error, turnspeculateoff and then evaluate the selected rows. Each keeps its ownprojection,projectedandspeculatewith a long doc comment, and the two already differ at exactly half (>inLazyLogical,<=here). Could they share one small type next toproject, something likeMasked { projection, projected, speculate }withfn evaluate(&self, batch, rows: Option<BooleanArray>, try_whole_batch: bool)? Each caller would keep only its threshold,LazyLogicalwould drop two fields, and the lazy projection from point 1 would only need to go in one place. -
spark/src/main/scala/org/apache/comet/serde/arithmetic.scala:50. Every caller ofcreateMathExpressionpassesskippable, so could it be a requiredExpressionrather thanOption[Expression] = None? Then a newMathExprserde that forgets it won't compile, instead of quietly evaluating both operands over the whole batch, which is the bug this PR fixes.
|
@tlfua I added |
- Share masked evaluation between NullShortCircuit and LazyLogical in a `Masked` type that projects its expression lazily on first evaluation, so nested short-circuits are planned in linear time. Speculate when at least half the rows need the expression in both. - Accept casts to string from integers, floating point numbers, booleans and Decimal128, and integer to Decimal128 with enough integral digits, in `Cast::cannot_fail`, so they are no longer masked. - Set the null short-circuit flag on each node of a rebalanced LEGACY integer add or multiply chain from its right slice. - Make `skippable` a required argument of `createMathExpression`. - Add a `concat_cast` benchmark shape and SQL tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7fbb6474-7dd6-4c0e-bb27-bd78a480ca36
|
@coderfender thanks for checking it so carefully. b948e2a addresses all four points:
Fork CI passed on b948e2a: https://github.com/tlfua/datafusion-comet/actions/runs/42754860727. The PR description has the updated table and test list. |
Which issue does this PR close?
Part of #7246. It fixes the arithmetic and
concatrows of that issue.OR,ANDandINwere fixed by #7326, and the later sort keys of a top-K are left for a separate PR, so the issue stays open.Rationale for this change
With ANSI mode on, native Comet evaluates an operand over the whole batch even on rows where Spark never evaluates it, so a query that Spark answers fails in Comet with
CAST_INVALID_INPUT:+,-and*evaluate the right operand only where the left one is not NULL (BinaryExpression.nullSafeCodeGen)./,DIVand%evaluate the divisor first, and the dividend only where the divisor is not NULL (DivModLike.doGenCode, the same in Spark 3.4 and 4.1).concatevaluates each argument only while no earlier one is NULL (Concat.doGenCode, Spark's default generated code).For example
SELECT n + CAST(txt AS INT) FROM treturns NULL in Spark wherevernis NULL, whatevertxtholds, but fails in Comet when such a row has a malformedtxt.What changes are included in this PR?
The fix reuses
NullShortCircuit, which already evaluates later function arguments only where no earlier one is NULL (#6716).NullShortCircuit::wrap_in_orderevaluates the arguments in a given order, so that the divisor of a division comes first.wrapkeeps the order of the children.MathExprgainsnull_short_circuit. The serde sets it when the operand that Spark may skip can be skipped (canSkipEvaluation), which is the right operand of+,-and*and the left one of/,DIVand%. The planner then wraps the arithmetic, including the checked ANSI functions, decimal arithmetic and the Decimal256 remainder (create_modulo_exprgains anull_short_circuitflag), keepingCheckedBinaryExproutermost so errors still carry the query context.skippableis a required argument ofcreateMathExpression, so a newMathExprserde cannot leave it out.CometConcatwraps the nativeconcatwithwithNullShortCircuitwhen it has more than one argument.LazyLogicaldoes since fix: evaluate the right operand of AND, OR and IN only for rows the left side has not decided #7326. Without speculation,a * (b - c)under ANSI, as in TPC-H Q1, was up to 2x slower on batches with NULLs, because selecting the rows costs more than the arithmetic. Whether to speculate depends on the cost of the argument:is_cheapis a list of what has been measured: columns, literals, integer and floating point+,-and*(including the ANSIchecked_add,checked_subandchecked_mul), and widening casts, over cheap children only. It can grow as more shapes are measured.Masked, shared byNullShortCircuitandLazyLogical, holds a masked argument: its projection, built lazily on the firstevaluateso that planning nested wrappers stays linear, and whether speculation still holds. Both speculate a costly argument when at least half the rows need it;LazyLogicalused to require more than half.Cast::cannot_failalso accepts casts that cannot fail for any value in any eval mode: from an integer to a decimal with enough integral digits, and from an integer, floating point number, boolean or decimal to a string. Dates and timestamps are left out because Arrow's formatter cannot format a value outsidechrono's range, which Spark can hold, and so are binary and nested types. Such arguments are not wrapped at all. They are added tocannot_failonly, not tois_infallible, which also decides which CASE WHEN branches are evaluated over the whole batch up front.is_deterministicmoves from private topub(super), acceptsCheckOverflow, and judges a nestedNullShortCircuitby its inner expression alone, so decimal and nested operands speculate too, and the check stays linear in the size of the tree.unwrap_checkedin the planner also looks throughNullShortCircuit, so the redundantCheckOverflowover a wide decimal add, subtract or multiply is still dropped.NullShortCircuitandwithNullShortCircuitdocs state that the function must return NULL wherever an earlier argument is NULL without checking the later ones, since with speculation it can see any value there.create_binary_exprlost its last caller and is removed. Its tests callcreate_binary_expr_with_options.An operand that Spark does evaluate still fails as before. A wrapped operand is NULL only on rows whose result is NULL anyway.
The same bug is on
branch-1.2, which hasNullShortCircuitbut not #7326, which this builds on. The backport is tracked in #7542.How are these changes tested?
spark/src/test/resources/sql-tests/expressions/math/arithmetic_ansi_skipped_operands.sqlandspark/src/test/resources/sql-tests/expressions/string/concat_ansi_skipped_arguments.sqlrun every operator over a batch where the failing operand is malformed only on rows that Spark skips, over one where every row is skipped, and overmostly_null, where three rows in four are skipped, so a cheap argument (n * (big - 1)) is speculated, overflows on a skipped row and falls back to selecting rows, and a costly one (n + CAST(txt AS INT)) selects the rows directly. They cover tinyint, smallint, int, bigint, double, decimal and the Decimal256 paths,try_addandtry_divide(includingtry_divide(CAST(txt AS INT), n)with a zero divisor, integer and decimal), and a zero divisor, where a NULL dividend gives NULL and a malformed one raisesCAST_INVALID_INPUTbefore the zero check, as in Spark.expect_error_class(CAST_INVALID_INPUT)queries check that an operand Spark evaluates still fails, including the reverse order (CAST(txt AS INT) + n,n DIV CAST(txt AS INT)). Before the fix, the firstqueryin each file fails withCAST_INVALID_INPUT.null_short_circuit.rs,lazy_logical.rsandmodulo_expr.rs: the divisor is evaluated first, it still fails where the dividend is NULL,ordermust be a permutation, the order surviveswith_new_children, speculative evaluation falls back to selected rows, a mostly-NULL batch selects the rows of a costly argument without speculating, a cheap one (checked_mul(a, checked_sub(b, c))) is speculated even with three rows in four skipped and still gives the right result when the skipped rows overflow, decimal and nested operands speculate,is_deterministicchecks 64 nested wrappers in linear time, 64 nested wrappers that each read a different column are planned in linear time in bothnull_short_circuit.rsandlazy_logical.rs, and an argument needed by exactly half the rows is speculated.test_check_overflow_skipped_over_short_circuited_wide_decimalinplanner.rschecks that the redundantCheckOverflowis still dropped over a wrapped wide decimal add.spark/src/test/resources/sql-tests/expressions/math/arithmetic_legacy_skipped_operands.sqlruns LEGACY integer+and*chains of 2 to 5 operands, including one that starts with aBIGINTcast, whose last operandelement_at(arr, i)fails on the rows Spark skips, so Comet must return NULL there. It still raisesINVALID_INDEX_OF_ZEROwhere Spark evaluates that operand, and where it comes first.concat_ansi_skipped_arguments.sqlalso has a query whose arguments cast an int, decimal, boolean and double to a string.cast.rs:test_cannot_fail_casts_succeed_on_extreme_values_in_every_modecasts the extreme values of each type thatcannot_failaccepts in LEGACY, TRY and ANSI mode, including decimals of scale 38 and -2, andtest_casts_that_can_fail_are_not_claimedchecks that one integral digit fewer, a negative scale, dates, timestamps, binary,Decimal256and lists are not accepted.native/spark-expr/benches/null_short_circuit.rscompares the eager form with the wrapped one at 0, 10, 50, 70 and 90% NULL, fora + CAST(s AS BIGINT),a * (b - c), the decimalp * CheckOverflow(1.00 - d)in ANSI and LEGACY mode, aWideDecimalBinaryExprmultiply, a multiply by a rescaling decimal cast,concat(q, upper(s)), andconcat(q, CAST(b AS STRING))over a longb, which is no longer wrapped. Anyone can rerun it withcd native && cargo bench -p datafusion-comet-spark-expr --bench null_short_circuit.µs per 8192-row batch, best of two rounds on one GitHub runner (run, from the measurement-only branch https://github.com/tlfua/datafusion-comet/tree/bench/7246-r4, which runs the benchmark on this head and on the source of the previous head c818be3). "eager" is the unwrapped expression, as on
main, which gives wrong errors.Full table
a + CAST(s AS BIGINT)a * (b - c)over longsp * (1.00 - d), ANSIp * (1.00 - d), LEGACYp * (d * d)p * CAST(d AS DECIMAL(12,4))concat(q, upper(s))concat(q, CAST(b AS STRING))What the numbers show:
concat(q, CAST(b AS STRING)), this head is within 2% of the previous head for every shape and NULL ratio.concat(q, CAST(b AS STRING))is no longer wrapped, so it now costs the same as eager at every NULL ratio. Wrapped, as on the previous head, it was faster at 50% NULL and above (1.14x at 50%, 1.47x at 70%, 2.07x at 90%), becauseconcatthen skips formatting the rows whose result is NULL anyway.concat(q, upper(s))is still up to 1.3x slower than eager at 50% NULL. Adding string functions tois_cheapis tracked in Speculate string functions such as upper in NullShortCircuit by adding them to is_cheap #7587.a * (b - c)is 1.2x to 2.2x faster than selecting rows at 10 to 70% NULL, while for the other shapes always speculating is 1.2x to 5x slower than selecting rows at 70 to 90% NULL, which is why only cheap arguments are always speculated.Commands run locally (macOS, default Spark profile), on b948e2a:
cargo test -p datafusion-comet-spark-expr: 1356 passed.cargo clippy -p datafusion-comet-spark-expr --all-targets -- -D warnings: passed.make format: no changes beyond this PR's files.python3 dev/ci/check-suites.pyand the otherdev/cichecks: passed.cargo bench -p datafusion-comet-spark-expr --bench null_short_circuit: run on a GitHub runner, numbers above.I did not run the Maven suites locally this round. Fork CI (Comet CI (fork run), default Spark profile) passed on b948e2a, including the new SQL tests, the Spark 4.1 Comet suites and the TPC-H and TPC-DS result checks.
I did not run the Spark SQL suite or the other Spark profiles locally. This changes the serde and native expressions, so a maintainer may want to apply
run-spark-4.1-tests, andrun-benchmark-checkfor the benchmark changes.