Skip to content

Conversation

pepijnve
Copy link
Contributor

@pepijnve pepijnve commented Oct 8, 2025

Which issue does this PR close?

Rationale for this change

For non-trivial case when ... then ... else ... end expressions the general code path is currently taken which is not very efficient. The original optimisation for these expressions was reverted in #15384. This PR restores the optimisation while avoiding the mistake that was made in the original implementation.

What changes are included in this PR?

  • The is_cheap_and_infallible precondition for the ExpressionOrExpression code path has been removed
  • Two guard clauses have been added for the when_value.true_count() == 0 and when_value.true_count() == batch.num_rows() cases. In these situations calling evaluate_selection for the branch that will never be taken is avoided.

Are these changes tested?

Covered by tests added in #15384

Are there any user-facing changes?

No

@github-actions github-actions bot added the physical-expr Changes to the physical-expr crates label Oct 8, 2025
Copy link
Contributor

@alamb alamb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pepijnve -- I think the idea is great -- thank you. I have one question about how it works, but I did verify test coverage and things look ok

I also queued up a benchmark run for this PR to gather some more information as well

FYI @findepi and @aweltsch

// Avoid evaluate_selection when all rows are false/null
return self.else_expr.as_ref().unwrap().evaluate(batch);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reading of this code is that it will still evaluate the then expression as long as there is at least one true value in when -- if evaluate_selection is a problem, shouldn't we fix evaluate_selection?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reading of this code is that it will still evaluate the then expression as long as there is at least one true value in when

Yes, that's correct. There's no way to avoid that.

This particular bit of code is both an optimisation and a correctness thing.

From a performance point of view, we already know the selection vector is redundant, so there's really no point in calling evaluate_selection.

For correctness, what's being avoid here is calling either then or else with a selection vector that will result in an empty record batch after filtering. We could add similar checks in evaluate_selection to prevent evaluating the downstream expression for empty record batches as well. Its current contract requires it to return an array with the same length as the unfiltered input batch though. You can't avoid having to create an all-nulls array then.

Copy link
Member

@findepi findepi Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reading of this code is that it will still evaluate the then expression as long as there is at least one true value in when

Yes, that's correct. There's no way to avoid that.

isn't this reintroducing the bug that was fixed in #15384, just in a big more complex wrapping?

Copy link
Contributor Author

@pepijnve pepijnve Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this reintroducing the bug that was fixed in #15384, just in a big more complex wrapping?

No, why do you think that's the case? If you write case foo is not null then foo else 1/0 end and foo happens to be NULL, what do you expect to happen?

The original issue was that in the example above for a single row with a non-null foo, the code was evaluating the then branch with [true] as selection vector and the else branch with [false]. The latter was passed to evaluate_selection which then filters the record batch down to an empty record batch and then calls the else expression with that record batch. For an expression like 1/0, you end up getting executing that division anyway even though the result would be discarded.

There are two ways to fix this:

  1. don't evaluate binary expressions and literals for empty batches (as you had suggested in a comment earlier I believe)
  2. don't call evaluate with empty input batches

The earlier fix had the effect of 2. as well, just in a less explicit way. The fix here does the same but adds the necessary checks in the explicit expr/expr code path. The code that's being seen as an optimisation is intended to prevent calling evaluate_selection with an all-false selection vector.

Copy link
Contributor Author

@pepijnve pepijnve Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I woke up early this morning to the realisation that the actual bug was a subtlety in the implementation of evaluate_selection. There's a difference between calling it with the empty set vs a non-empty set and a false selection vector. The implementation was actually treating both cases identically which can cause a spurious row to get materialised. I've pushed a correction for this and tweaked the comments in the code a bit.

I believe this properly addresses the original evaluation problem. All SQL logic tests pass even when commenting out the optimisation for true and false selection vectors in expr_or_expr.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the change in evaluate_selection
Now that evaluate_selection is changed, do we need those lines here?

(They look nice but my only concern is additional code complexity which is harder to cover with SLT tests. Now that we have branching here, we should have a bunch of SLT cases that clearly exercise all-true, all-false, some-true situations.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, more SLTs! I will add some for the various cases you mentioned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that evaluate_selection is changed, do we need those lines here?

@findepi Yes I would prefer to keep these early outs since they're pretty trivial and I think they're appropriate for the expr_or_expr function since the knowledge that it's "then or else" is located here. evaluate_selection cannot be implemented with awareness of this particular usage pattern.

Calling evaluate_selection still has to produce a value –it can't return None– so you pay at least a non-zero cost for calling it unnecessarily. If it's obvious that it will not perform any useful work, it makes sense to avoid it IMO.

Just for context, the queries I'm working on are quite case heavy. Any work we can save in the inner loop of the queries seems worthwhile.

@alamb
Copy link
Contributor

alamb commented Oct 9, 2025

🤖 ./gh_compare_branch_bench.sh Benchmark Script Running
Linux aal-dev 6.14.0-1016-gcp #17~24.04.1-Ubuntu SMP Wed Sep 3 01:55:36 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
Comparing issue_17972 (cf78119) to 07a7eb2 diff
BENCH_NAME=case_when
BENCH_COMMAND=cargo bench --bench case_when
BENCH_FILTER=
BENCH_BRANCH_NAME=issue_17972
Results will be posted here when complete

@alamb
Copy link
Contributor

alamb commented Oct 9, 2025

🤖: Benchmark completed

Details

group                          issue_17972                            main
-----                          -----------                            ----
case_when: CASE expr           1.00     24.0±0.26µs        ? ?/sec    1.01     24.4±0.19µs        ? ?/sec
case_when: column or null      1.00   1409.5±2.43ns        ? ?/sec    1.01   1421.6±3.52ns        ? ?/sec
case_when: expr or expr        1.00     31.0±0.29µs        ? ?/sec    1.01     31.3±0.16µs        ? ?/sec
case_when: scalar or scalar    1.00      7.9±0.02µs        ? ?/sec    1.00      7.9±0.03µs        ? ?/sec

@alamb
Copy link
Contributor

alamb commented Oct 9, 2025

🤖 ./gh_compare_branch_bench.sh Benchmark Script Running
Linux aal-dev 6.14.0-1016-gcp #17~24.04.1-Ubuntu SMP Wed Sep 3 01:55:36 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
Comparing issue_17972 (cf78119) to 07a7eb2 diff
BENCH_NAME=case_when
BENCH_COMMAND=cargo bench --bench case_when
BENCH_FILTER=
BENCH_BRANCH_NAME=issue_17972
Results will be posted here when complete

@alamb
Copy link
Contributor

alamb commented Oct 9, 2025

🤖: Benchmark completed

Details

group                          issue_17972                            main
-----                          -----------                            ----
case_when: CASE expr           1.00     24.4±0.38µs        ? ?/sec    1.02     24.9±0.25µs        ? ?/sec
case_when: column or null      1.00  1406.5±11.79ns        ? ?/sec    1.01   1414.3±8.58ns        ? ?/sec
case_when: expr or expr        1.00     31.3±0.27µs        ? ?/sec    1.01     31.7±0.30µs        ? ?/sec
case_when: scalar or scalar    1.00      7.9±0.03µs        ? ?/sec    1.00      7.9±0.18µs        ? ?/sec

Comment on lines 432 to 438
if true_count == batch.num_rows() {
// Avoid evaluate_selection when all rows are true
return self.when_then_expr[0].1.evaluate(batch);
} else if true_count == 0 {
// Avoid evaluate_selection when all rows are false/null
return self.else_expr.as_ref().unwrap().evaluate(batch);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per @alamb comment https://github.com/apache/datafusion/pull/17973/files#r2417644396, those lines look like an optimization (and a reasonable one).
but the code should also work if we take them out, just a bit slower. Do all the test pass if we comment this block out?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, you're back to the original issue for which you added the guard clauses then. The fix back then was local, so I applied a local fix in this PR as well.

I've pushed an extra commit that adds an extra guard clause in evaluate_selection as well. With that change you can comment out this block and all SQL logic tests still pass.

let selection_count = selection.true_count();

let tmp_result = self.evaluate(&tmp_batch)?;
if batch.num_rows() == 0 || selection_count == batch.num_rows() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if batch.num_rows() == 0 || selection_count == batch.num_rows() {
if selection_count == batch.num_rows() {

(i'd assume batch.num_rows() == 0 implies that also selection_count == 0. If it is not the case, we have a more permissive if condition, but requires a code comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was no assertion for this in place and as far as I can tell (but I did not verify yet), you can call evaluate_selection today with a mismatch between batch.num_rows() and selection.len() and it will do something. I haven't tested this yet, so I'm not 100% sure what the outcome would be.
I'll write an extra unit test to cover this.

The idea here is to avoid any extra work whatsoever in the trivial cases. Not sure what a useful, non-trivial comment for that would be.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alamb @findepi I've added some unit tests to help define the behaviour of evaluate_selection. Some are currently still failing. Could you guys take a look at the tests to see if what they're asserting is correct?

The issues are all related to record batch / selection vector size mismatches. I don't think the current behaviour makes sense tbh (which is also present on main). I would expect to either get an error or as many output rows as there were input rows.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken the liberty of adding the size mismatch check and returning an execution error in case of mismatch. Within the DataFusion library case is the only client of this API and for that this change should be fine. The behaviour in case of mismatch was pretty weird, so I doubt people would be making active use of this. You never know of course. I'll add this to the user visible changes list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a couple of tests guide by code coverage.expr_or_expr is definitely well covered.

let tmp_batch = filter_record_batch(batch, selection)?;
let selection_count = selection.true_count();

let tmp_result = self.evaluate(&tmp_batch)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of new code which repeats logic of filter_record_batch.
What if we just changed this line only?

let tmp_result =  if tmp_batch.is_empty {
  // Do not call `evaluate` when the selection is empty.
            // When `evaluate_selection` is being used for conditional, lazy evaluation,
            // evaluating an expression for a false selection vector may end up unintentionally
            // evaluating a fallible expression.
            let datatype = self.data_type(batch.schema_ref().as_ref())?;
            ColumnarValue::Array(make_builder(&datatype, 0).finish())
} else {
  self.evaluate(&tmp_batch)?;
}

Note how this does not inspect selection / selection_count directly, leveraging the work done by filter_record_batch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing it, but I don't see the overlap with filter_record_batch. AFAICT there are no checks to avoid creating a new record batch. What this code is doing is preparing an empty result value while filter_record_batch has optimised code to an empty record batch if the filter is all-false.

// Avoid evaluate_selection when all rows are false/null
return self.else_expr.as_ref().unwrap().evaluate(batch);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the change in evaluate_selection
Now that evaluate_selection is changed, do we need those lines here?

(They look nice but my only concern is additional code complexity which is harder to cover with SLT tests. Now that we have branching here, we should have a bunch of SLT cases that clearly exercise all-true, all-false, some-true situations.)

- Add extra comments
- Use match for the scatter paragraph
- Validate that the size of selection and batch match
@github-actions github-actions bot added the sqllogictest SQL Logic Tests (.slt) label Oct 10, 2025
@alamb
Copy link
Contributor

alamb commented Oct 14, 2025

CI failure seems unrelated to this PR:

@pepijnve
Copy link
Contributor Author

I updated this PR again since the failing test was reverted on main

@alamb alamb added this pull request to the merge queue Oct 15, 2025
@alamb
Copy link
Contributor

alamb commented Oct 15, 2025

Thanks again @pepijnve 🙏 -

Merged via the queue into apache:main with commit 6d3854f Oct 15, 2025
28 checks passed
@alamb alamb added the performance Make DataFusion faster label Oct 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Make DataFusion faster physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restore expr/expr optimisation for case expressions

3 participants