Skip to content

perf: optimize nanvl in datafusion-functions#23458

Merged
Dandandan merged 3 commits into
apache:mainfrom
andygrove:auto-opt/nanvl-datafusion-20260710-114844
Jul 11, 2026
Merged

perf: optimize nanvl in datafusion-functions#23458
Dandandan merged 3 commits into
apache:mainfrom
andygrove:auto-opt/nanvl-datafusion-20260710-114844

Conversation

@andygrove

@andygrove andygrove commented Jul 10, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A

Rationale for this change

Improve performance of existing function.

What changes are included in this PR?

Added a null-free fast path to the nanvl array kernel that iterates the raw value slices (generic across Float16/Float32/Float64) instead of per-element Option iteration plus collect, eliminating null-bookkeeping overhead on the common no-null input.

Are these changes tested?

Existing tests + one new unit test

Benchmark (criterion):

Wins:

  • nanvl_array_f64_1024: 92.229% faster (base 3250ns -> cand 252ns)
  • nanvl_array_f32_1024: 95.031% faster (base 3189ns -> cand 158ns)
  • nanvl_array_f64_4096: 93.826% faster (base 12679ns -> cand 782ns)
  • nanvl_array_f32_4096: 96.952% faster (base 12549ns -> cand 382ns)
  • nanvl_array_f64_8192: 93.375% faster (base 25792ns -> cand 1708ns)
  • nanvl_array_f32_8192: 96.997% faster (base 24943ns -> cand 749ns)

Within noise:

  • nanvl_scalar_f64: -0.406% faster (base 36ns -> cand 36ns)
  • nanvl_scalar_f32: -1.367% faster (base 36ns -> cand 37ns)

Partially-null inputs (base = main, cand = this PR). Even on inputs
containing nulls the rewritten kernel is faster than main:

  • nanvl_array_f64_x_nulls_1024: 45.9% faster (base 3496ns -> cand 1893ns)
  • nanvl_array_f64_y_nulls_1024: 39.2% faster (base 3300ns -> cand 2007ns)
  • nanvl_array_f64_both_nulls_1024: 47.5% faster (base 3713ns -> cand 1949ns)
  • nanvl_array_f64_x_nulls_4096: 41.5% faster (base 13360ns -> cand 7818ns)
  • nanvl_array_f64_y_nulls_4096: 34.5% faster (base 12887ns -> cand 8439ns)
  • nanvl_array_f64_both_nulls_4096: 46.0% faster (base 14506ns -> cand 7835ns)
  • nanvl_array_f64_x_nulls_8192: 47.0% faster (base 27516ns -> cand 14582ns)
  • nanvl_array_f64_y_nulls_8192: 42.1% faster (base 26585ns -> cand 15398ns)
  • nanvl_array_f64_both_nulls_8192: 49.0% faster (base 29647ns -> cand 15119ns)

Also added benchmarks and unit tests for partially-null inputs (only-x-null, only-y-null, both-null). Splitting the null-aware path into per-configuration match arms was evaluated with these benchmarks but regressed performance by 8-16% (the larger function body penalizes even the unchanged both-null arm), so only the coverage additions were kept.

Are there any user-facing changes?

No

@github-actions github-actions Bot added the functions Changes to functions implementation label Jul 10, 2026
@andygrove andygrove marked this pull request as ready for review July 10, 2026 18:31
@andygrove andygrove added the performance Make DataFusion faster label Jul 10, 2026

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice improvement overall — the null-free fast path clearly removes a lot of Option and null bookkeeping overhead, and the benchmark wins in the PR description are compelling.

One additional optimization opportunity: the current fallback path handles all null-containing cases together, so it still pays for x.is_valid(i) and, in the NaN case, y.is_valid(i) checks inside a single generic loop. I think this can be tightened further by splitting the fallback into separate cases for:

  • (None, Some(_))
  • (Some(_), None)
  • (Some(_), Some(_))

That should reduce branching and avoid unnecessary validity checks for partially-null inputs while preserving the same semantics.

Concretely, I’d expect something along these lines in nanvl_impl:

match (x.nulls(), y.nulls()) {
    (None, None) => { /* current fast path */ }
    (None, Some(_)) => { /* only y validity matters when x_value.is_nan() */ }
    (Some(_), None) => { /* only x validity matters; y validity checks disappear */ }
    (Some(_), Some(_)) => { /* current generic null-aware path */ }
}

Also, a couple of smaller follow-ups:

  • the PR description says “raw f64 value slices”, but the implementation is now generic across Float16/32/64
  • it would be useful to add benchmarks for partially-null inputs too, since the current numbers mostly validate the no-null fast path

Adds benchmarks for the three null-configuration match arms (only-x-null,
only-y-null, both-null) and unit tests covering the single-null-input cases.

These benchmarks were used to evaluate splitting the null-aware path into
per-configuration arms; measurements showed that split regresses performance
by 8-16%, so only the coverage additions are kept.
@andygrove

Copy link
Copy Markdown
Member Author

Nice improvement overall — the null-free fast path clearly removes a lot of Option and null bookkeeping overhead, and the benchmark wins in the PR description are compelling.

One additional optimization opportunity: the current fallback path handles all null-containing cases together, so it still pays for x.is_valid(i) and, in the NaN case, y.is_valid(i) checks inside a single generic loop. I think this can be tightened further by splitting the fallback into separate cases for:

  • (None, Some(_))
  • (Some(_), None)
  • (Some(_), Some(_))

That should reduce branching and avoid unnecessary validity checks for partially-null inputs while preserving the same semantics.

Concretely, I’d expect something along these lines in nanvl_impl:

match (x.nulls(), y.nulls()) {
    (None, None) => { /* current fast path */ }
    (None, Some(_)) => { /* only y validity matters when x_value.is_nan() */ }
    (Some(_), None) => { /* only x validity matters; y validity checks disappear */ }
    (Some(_), Some(_)) => { /* current generic null-aware path */ }
}

Also, a couple of smaller follow-ups:

  • the PR description says “raw f64 value slices”, but the implementation is now generic across Float16/32/64
  • it would be useful to add benchmarks for partially-null inputs too, since the current numbers mostly validate the no-null fast path

I tried out the main suggestion here and saw worse performance. Curious to know if you can reproduce this?

@andygrove

Copy link
Copy Markdown
Member Author

Also, a couple of smaller follow-ups:

  • the PR description says “raw f64 value slices”, but the implementation is now generic across Float16/32/64
  • it would be useful to add benchmarks for partially-null inputs too, since the current numbers mostly validate the no-null fast path

I addressed these points. Thanks.

@andygrove

Copy link
Copy Markdown
Member Author

I tried out the main suggestion here and saw worse performance. Curious to know if you can reproduce this?

I ran the benchmarks again and see a ~11-15% regression with the proposed approach. Claude tells me that this is due to a "whole-function codegen penalty".

@comphead

Copy link
Copy Markdown
Contributor

I ran the benchmarks again and see a ~11-15% regression with the proposed approach. Claude tells me that this is due to a "whole-function codegen penalty".

Thanks for trying that, its just what caught my eye, anyway current PR already brings up a benefit

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @andygrove

Comment thread datafusion/functions/src/math/nanvl.rs Outdated
/// splits out a null-free fast path that iterates the raw value slices,
/// skipping per-element validity checks and `Option` handling. The null-aware
/// path builds its null buffer lazily via [`NullBufferBuilder`] exactly as the
/// `FromIterator` implementation does, so the result is byte-for-byte the same.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

probably can remove reference to previous version (like how it mentions result is byte-for-byte the same)

let mut values = Vec::with_capacity(len);
for i in 0..len {
// `y` is only consulted when `x` is a (non-null) NaN, matching
// the original short-circuiting match.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same here, no need to mention original short-circuiting match considering the code will be gone

Comment thread datafusion/functions/src/math/nanvl.rs Outdated
@Dandandan Dandandan enabled auto-merge July 11, 2026 06:37
@Dandandan Dandandan added this pull request to the merge queue Jul 11, 2026
Merged via the queue into apache:main with commit 479a0aa Jul 11, 2026
36 checks passed
@andygrove andygrove deleted the auto-opt/nanvl-datafusion-20260710-114844 branch July 11, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation performance Make DataFusion faster

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants