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
|
Thanks @coderfender, all four are addressed in 9b6c7be. The full table is in the updated description (bench run).
|
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. The rebalanced LEGACY add and multiply chains are unchanged.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.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, andis_deterministicchecks 64 nested wrappers in linear time.test_check_overflow_skipped_over_short_circuited_wide_decimalinplanner.rschecks that the redundantCheckOverflowis still dropped over a wrapped wide decimal add.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, andconcat(q, upper(s)). 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 a GitHub runner (run, from the measurement-only branch https://github.com/tlfua/datafusion-comet/tree/bench/7246, which adds a
COMET_NSC_SPECULATEswitch to force always or never speculating). "eager" is the unwrapped expression, as onmain, 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))What the numbers show:
a * (b - c), the only shape on the cheap list, is within 5% of eager at every NULL ratio. Always speculating it is 1.2x to 2.2x faster than selecting rows at 10 to 70% NULL, and 2.3µs (1.3x) slower at 90%.concat(q, upper(s))is the exception, and the only shape more than 6% slower than eager: 1.4x at 50% NULL and 1.2x at 70%, because selecting string rows is costly. Always speculating it would bring that to 1.09x and 1.04x and cost 5% at 90%, so infallible string functions are a candidate for the cheap list, but each needs measuring, so I left that for a follow-up.Commands run locally (macOS, default Spark profile):
cargo test -p datafusion-comet-spark-expr --lib -- null_short_circuit lazy_logical case_when: 46 passed (on 9b6c7be).cargo clippy --all-targets --workspace -- -D warnings,cargo fmt --all --check: passed (on 9b6c7be)../mvnw test -Dsuites="org.apache.comet.CometSqlFileTestSuite skipped" -Dtest=none,... arithmeticand... concat: passed;cargo test -p datafusion-comet --lib -- planner: 123 passed;./mvnw spotless:check: passed.cargo bench -p datafusion-comet-spark-expr --bench null_short_circuit: run on a GitHub runner, numbers above.Fork CI (Comet CI (fork run), default Spark profile) passed on 9b6c7be, 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.