Skip to content

perf: Optimize repeated counting of set bits - #26507

Open
neilconway wants to merge 6 commits into
apache:mainfrom
neilconway:neilc/perf-set-bit-counter
Open

neilconway wants to merge 6 commits into
apache:mainfrom
neilconway:neilc/perf-set-bit-counter

Conversation

@neilconway

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

There are several places where we want to count the set bits within a certain range of indices in a bitmap: for example, to count how many non-null elements are in a given row of a list column. Previously, this was typically done by slicing the bitmap and then calling Arrow's count_set_bits_offset. This has a lot of per-call overhead, which can be avoided when we want to count bits repeatedly on the same bitmap (e.g., for each row in the scenario above). #25754 introduced SetBitCounter as a small abstraction to do this more efficiently for the array_filter UDF. This PR refines that API, makes it public, and uses it in several other places in DataFusion: Spark's array_contains and map_from_entries UDFs, and core's array_compact, array_remove, inner_product, cosine_distance, and array_normalize UDFs.

Benchmarks: (x86, AMD EPYC-Milan)

vector_math

  • inner_product/width_4/no_nulls: 1.57 ms → 175.940 µs (−88.8%)
  • cosine_distance/width_4/no_nulls: 1.82 ms → 390.200 µs (−78.5%)
  • array_normalize/width_4/no_nulls: 1.47 ms → 280.990 µs (−80.9%)
  • inner_product/width_4/null_elements: 2.41 ms → 451.880 µs (−81.2%)
  • cosine_distance/width_4/null_elements: 2.46 ms → 657.260 µs (−73.2%)
  • array_normalize/width_4/null_elements: 1.47 ms → 426.120 µs (−71.0%)
  • inner_product/width_32/no_nulls: 577.000 µs → 64.133 µs (−88.9%)
  • cosine_distance/width_32/no_nulls: 322.610 µs → 138.470 µs (−57.1%)
  • array_normalize/width_32/no_nulls: 637.830 µs → 145.690 µs (−77.2%)
  • inner_product/width_32/null_elements: 611.040 µs → 97.872 µs (−84.0%)
  • cosine_distance/width_32/null_elements: 378.080 µs → 160.790 µs (−57.5%)
  • array_normalize/width_32/null_elements: 598.450 µs → 157.790 µs (−73.6%)
  • inner_product/width_256/no_nulls: 452.730 µs → 92.799 µs (−79.5%)
  • cosine_distance/width_256/no_nulls: 136.570 µs → 112.350 µs (−17.7%)
  • array_normalize/width_256/no_nulls: 538.780 µs → 187.670 µs (−65.2%)
  • inner_product/width_256/null_elements: 379.930 µs → 88.551 µs (−76.7%)
  • cosine_distance/width_256/null_elements: 123.600 µs → 103.470 µs (−16.3%)
  • array_normalize/width_256/null_elements: 494.110 µs → 176.980 µs (−64.2%)

array_remove (scalar needle)

  • array_remove_int64 size 10, rows 4000: 226.790 µs → 200.230 µs (−11.7%)
  • array_remove_int64 size 100, rows 10000: 1.31 ms → 1.29 ms (−2.1%)
  • array_remove_int64 size 500, rows 10000: 6.34 ms → 6.27 ms (−1.1%)
  • array_remove_n_int64 size 10, rows 4000: 244.930 µs → 208.590 µs (−14.8%)
  • array_remove_n_int64 size 100, rows 10000: 2.27 ms → 2.28 ms (+0.4%)
  • array_remove_n_int64 size 500, rows 10000: 11.34 ms → 11.30 ms (−0.3%)
  • array_remove_all_int64 size 10, rows 4000: 234.380 µs → 211.390 µs (−9.8%)
  • array_remove_all_int64 size 100, rows 10000: 3.36 ms → 3.37 ms (+0.3%)
  • array_remove_all_int64 size 500, rows 10000: 15.24 ms → 15.36 ms (+0.8%)
  • array_remove_strings size 10, rows 4000: 389.410 µs → 377.810 µs (−3.0%)
  • array_remove_strings size 100, rows 10000: 7.31 ms → 7.35 ms (+0.6%)
  • array_remove_strings size 500, rows 10000: 47.14 ms → 46.79 ms (−0.8%)
  • array_remove_boolean size 10, rows 4000: 314.650 µs → 274.600 µs (−12.7%)
  • array_remove_boolean size 100, rows 10000: 1.66 ms → 1.63 ms (−1.9%)
  • array_remove_boolean size 500, rows 10000: 5.13 ms → 5.10 ms (−0.5%)
  • array_remove_fixed_size_binary size 10, rows 4000: 391.640 µs → 346.230 µs (−11.6%)
  • array_remove_fixed_size_binary size 100, rows 10000: 5.80 ms → 5.71 ms (−1.5%)
  • array_remove_fixed_size_binary size 500, rows 10000: 68.23 ms → 68.33 ms (+0.1%)

array_contains (Spark)

  • array_contains/list/width_4: 183.660 µs → 60.303 µs (−67.2%)
  • array_contains/list/width_16: 214.510 µs → 99.500 µs (−53.6%)
  • array_contains/list/width_128: 503.060 µs → 451.970 µs (−10.2%)
  • array_contains/fixed_size_list/width_16: 200.670 µs → 103.220 µs (−48.6%)

map_from_entries (Spark)

  • map_from_entries/width_4: 2.34 ms → 2.15 ms (−8.2%)
  • map_from_entries/width_16: 8.58 ms → 8.31 ms (−3.1%)
  • map_from_entries/width_64: 34.26 ms → 33.30 ms (−2.8%)

array_compact (non-primitive values)

  • dictionary/mixed_nulls/2048: 6.259 µs → 3.669 µs (−41.4%)
  • dictionary/mixed_nulls/1048576: 6.363 µs → 3.788 µs (−40.5%)
  • random/utf8/random_sparse: 5.977 µs → 3.324 µs (−44.4%)
  • random/utf8/random: 7.024 µs → 4.209 µs (−40.1%)
  • random/utf8/random_half: 7.049 µs → 4.276 µs (−39.3%)
  • random/utf8/random_dense: 4.071 µs → 1.407 µs (−65.4%)
  • random/utf8_view/random_sparse: 4.688 µs → 2.055 µs (−56.2%)
  • random/utf8_view/random: 6.596 µs → 3.954 µs (−40.1%)
  • random/utf8_view/random_half: 5.629 µs → 2.977 µs (−47.1%)
  • random/utf8_view/random_dense: 3.882 µs → 1.209 µs (−68.9%)
  • types/utf8/mixed: 7.634 µs → 4.906 µs (−35.7%)
  • nulls/utf8/clustered: 5.481 µs → 2.793 µs (−49.0%)
  • types/boolean/mixed: 7.367 µs → 4.714 µs (−36.0%)
  • types/nested/sparse: 8.943 µs → 6.175 µs (−31.0%)
  • types/struct_nested/sparse: 9.526 µs → 6.798 µs (−28.6%)
  • types/fixed_nested/sparse: 14.481 µs → 11.780 µs (−18.7%)
  • types/map/sparse: 12.709 µs → 10.038 µs (−21.0%)
  • types/run/mixed: 11.296 µs → 8.685 µs (−23.1%)
  • strings/utf8_long: 13.171 µs → 10.547 µs (−19.9%)
  • shape/utf8/width_128: 3.451 µs → 3.307 µs (−4.2%)
  • parent_nulls/utf8/some: 108.580 µs → 109.120 µs (+0.5%)
  • parent_nulls/utf8/mostly: 3.075 µs → 4.381 µs (+42.5%)
  • parent_nulls/nested/mostly: 12.639 µs → 13.844 µs (+9.5%)
  • large_list/utf8: 7.754 µs → 4.926 µs (−36.5%)
  • child_slice/utf8: 7.717 µs → 4.924 µs (−36.2%)
  • large/utf8/random_tenth: 3.77 ms → 3.12 ms (−17.1%)
  • large/utf8/random_half: 4.27 ms → 3.54 ms (−17.2%)
  • large/utf8_long/random_half: 84.19 ms → 83.72 ms (−0.6%)
  • binary/random_half: 10.583 µs → 7.737 µs (−26.9%)
  • large_binary/random_half: 10.532 µs → 8.003 µs (−24.0%)
  • strings/null_payload: 7.930 µs → 2.502 µs (−68.4%)
  • strings/null_payload_null_rows: 1.545 µs → 1.553 µs (+0.6%)

array_filter (lists with NULL rows)

  • int32/keep50/null_rows50: 4.909 µs → 4.867 µs (−0.9%)
  • int32/keep50/null_rows99: 1.440 µs → 1.445 µs (+0.4%)
  • utf8/keep50/null_rows50: 14.942 µs → 14.110 µs (−5.6%)
  • utf8/keep50/null_rows99: 1.650 µs → 1.589 µs (−3.7%)
  • utf8_view/keep50/null_rows50: 6.849 µs → 7.428 µs (+8.5%)
  • utf8_view/keep50/null_rows99: 1.556 µs → 1.505 µs (−3.3%)
  • int32/keep50/null_rows50/hidden_storage: 7.417 µs → 7.387 µs (−0.4%)

What changes are included in this PR?

  • Add benchmarks for Spark's array_contains and map_from_entries and core's inner_product, cosine_distance, and array_normalize
  • Move SetBitCounter to datafusion-common, make it public, and generalize the API
  • Adopt new SetBitCounter API in array_remove, inner_product, cosine_distance, and array_normalize core UDFs, and in array_contains and map_from_entries Spark UDFs
  • Build array_compact's result for non-primitive values with prepare_list_filter, which array_filter already uses. This is slower when NULL list rows still store elements, which is rare but can occur (e.g., the result of nullif). For such inputs, SetBitCounter reads the validity bits for "hidden" rows, which the old loop skipped. To keep that cost down, prepare_list_filter now handles each run of NULL rows between two valid rows with one count and one append, instead of one per NULL row. The remaining cost shows in extreme cases: parent_nulls/utf8/mostly (99% of rows NULL, 256 elements each) is 42% slower and parent_nulls/nested/mostly is 9.5% slower. A counter that skips the words under NULL rows avoided this, but it slowed array_filter on lists without NULL rows by 34% on x86, so I didn't pursue it.

What is the testing strategy for this PR?

Existing tests pass; new tests added.

Are there any user-facing changes?

New public SetBitCounter API.

…arks

Cover Float64 lists of 4, 32, and 256 elements per row, with the same
total number of elements, both without NULL elements and with one NULL
element in 10% of rows.
array_contains: lists of 4, 16, and 128 Int32 elements and a fixed-size
list of 16, with 10% NULL elements and a value that never occurs, so
every row checks whether its list contains a NULL element.

map_from_entries: lists of 4, 16, and 64 entries with unique keys, where
10% of rows contain one NULL entry.
…ulls

Make SetBitCounter public in datafusion_common::utils so other crates
can count the set bits in each row of a list in one pass, and add
count_range for the set bits within one row, which prepare_list_filter
now uses for the elements stored under NULL rows.

Add rows_have_nulls, which returns a bitmap of the list rows that
contain a NULL element. It counts from the list's first element, which
can be far into the validity bitmap when the list is sliced, builds the
bitmap with BooleanBuffer::collect_bool, and reuses each row's end count
as the next row's start.
array_remove, inner_product, cosine_distance, and array_normalize sliced
a bitmap or the values array for each list row to count its set bits or
NULLs. Each slice clones a reference-counted buffer and sets up a new
bit count.

array_remove now counts each row's matches with SetBitCounter, which
reads each 64-bit word of the bitmap once over the whole list, and
slices a row's bits only when it has matches to remove. The vector
functions get the rows that contain a NULL from rows_have_nulls, through
a new rows_with_null_elements helper, and read each row's values as a
plain slice of the values buffer.

Test inner_product, cosine_distance, and array_remove with a scalar
needle through check_sliced_list_behavior, and pad its input by a length
that is not a multiple of 64, so the list's elements start partway
through a 64-bit word.

x86 (AMD EPYC-Milan, Hetzner ccx33), the branch against main, fastest
of 3 alternating rounds; no other commit on the branch changes these
functions:

                                 no_nulls  null_elements
  inner_product/width_4            -88.8%         -81.2%
  inner_product/width_32           -88.9%         -84.0%
  inner_product/width_256          -79.5%         -76.7%
  cosine_distance/width_4          -78.5%         -73.2%
  cosine_distance/width_32         -57.1%         -57.5%
  cosine_distance/width_256        -17.7%         -16.3%
  array_normalize/width_4          -80.9%         -71.0%
  array_normalize/width_32         -77.2%         -73.6%
  array_normalize/width_256        -65.2%         -64.2%

array_remove with a scalar needle, lists of 10 elements:

  array_remove_int64                -11.7%
  array_remove_n_int64              -14.8%
  array_remove_all_int64             -9.8%
  array_remove_strings               -3.0%
  array_remove_boolean              -12.7%
  array_remove_fixed_size_binary    -11.6%

With lists of 100 and 500 elements, which do more work per row,
array_remove moved -2.1% to +0.8%. The nested-list cases, whose list
needle takes a path this does not change, moved -1.9% to +2.7%.
…and map_from_entries

Both sliced the element validity bitmap for each list row to check
whether the row contains a NULL. Use rows_have_nulls, which counts the
valid elements of each row in one pass over the bitmap.

Add tests that a sliced list gives the same rows as the whole list.

x86 (AMD EPYC-Milan, Hetzner ccx33), the branch against main, fastest
of 3 alternating rounds; no other commit on the branch changes these
functions:

  array_contains/list/width_4               -67.2%
  array_contains/list/width_16              -53.6%
  array_contains/list/width_128             -10.2%
  array_contains/fixed_size_list/width_16   -48.6%
  map_from_entries/width_4                   -8.2%
  map_from_entries/width_16                  -3.1%
  map_from_entries/width_64                  -2.8%
For lists of non-primitive values, array_compact counted each valid
row's non-NULL elements with count_set_bits_offset, which has a fixed
cost per call that dominates for short rows, in a loop that otherwise
repeated prepare_list_filter. Call prepare_list_filter instead, which
counts with SetBitCounter in one pass over the element validity and
reuses that validity as the keep bitmap until a NULL row stores
elements.

The old array_compact loop did no work for NULL rows, but
prepare_list_filter counted each NULL row's set bits and appended its
cleared bits to the keep bitmap one row at a time. Calling it as it was
made array_compact nearly three times slower when most rows were NULL
and stored elements. prepare_list_filter now handles the elements
between two valid rows together: when a valid row starts after the
previous one ended, it counts the set bits in between once and clears
them with one append, and it clears any elements after the last valid
row at the end. A NULL row only adds its offset. A valid row that
directly follows the previous valid row still costs one count, so
inputs whose NULL rows store no elements do the same work as before.
array_filter also builds its keep bitmap with prepare_list_filter, so
it shares this handling.

x86 (AMD EPYC-Milan, Hetzner ccx33), the branch against main, fastest
of 3 alternating rounds; no other commit on the branch changes
array_compact:

  dictionary/mixed_nulls/2048           -41.4%
  dictionary/mixed_nulls/1048576        -40.5%
  random/utf8/random_sparse             -44.4%
  random/utf8/random                    -40.1%
  random/utf8/random_half               -39.3%
  random/utf8/random_dense              -65.4%
  random/utf8_view/random_sparse        -56.2%
  random/utf8_view/random               -40.1%
  random/utf8_view/random_half          -47.1%
  random/utf8_view/random_dense         -68.9%
  types/utf8/mixed                      -35.7%
  nulls/utf8/clustered                  -49.0%
  types/boolean/mixed                   -36.0%
  types/nested/sparse                   -31.0%
  types/struct_nested/sparse            -28.6%
  types/fixed_nested/sparse             -18.7%
  types/map/sparse                      -21.0%
  types/run/mixed                       -23.1%
  strings/utf8_long                     -19.9%
  shape/utf8/width_128                   -4.2%
  large_list/utf8                       -36.5%
  child_slice/utf8                      -36.2%
  large/utf8/random_tenth               -17.1%
  large/utf8/random_half                -17.2%
  large/utf8_long/random_half            -0.6%
  binary/random_half                    -26.9%
  large_binary/random_half              -24.0%
  strings/null_payload                  -68.4%

With NULL rows that store elements:

  parent_nulls/utf8/some                 +0.5%
  parent_nulls/utf8/mostly              +42.5%
  parent_nulls/nested/mostly             +9.5%
  strings/null_payload_null_rows         +0.6%

Lists whose rows are mostly NULL but still store elements remain slower
than before, because SetBitCounter counts every 64-bit word of the
element validity, including the words under NULL rows, which the old
loop never read. NULL rows that store elements are rare.

Cases with primitive values, which take a separate path, and cases that
return before reaching this code moved -2.0% to +5.3%. array_filter's
cases with NULL rows moved -5.6% to +8.5%, and its cases without NULL
rows, which run none of the code this changes, moved -5.7% to +12.1%.
@github-actions github-actions Bot added common Related to common crate functions Changes to functions implementation spark labels Oct 19, 2026
@neilconway

Copy link
Copy Markdown
Contributor Author

FYI @comphead -- followup to #21895 as discussed.

@comphead

Copy link
Copy Markdown
Contributor

nice

@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 for the thorough benchmarks. The prepare_list_filter run-of-NULL-rows rewrite reads correct to me for leading, interior and trailing hidden rows. Suggestions below, none blocking.

Small items:

  • SetBitCounter::count_range runs count_before(start) for every row, but for contiguous rows start equals the previous end, so the same mask and popcount is repeated. rows_have_nulls already carries the previous count by hand. Caching the last (end, count) inside the counter would give both call sites the same behavior. Unmeasured, so treat it as optional.
  • prepare_list_filter builds SetBitCounter::new(keep.bit_chunks().iter_padded()) in both branches. It can be hoisted above the if.
  • map_from_entries.rs: .and_then(|nulls| Some(..).filter(..)) is a .map(..).filter(..). The inversion !&has_nulls also allocates a second bitmap and then null_count scans it again.

}

impl<I: Iterator<Item = u64>> SetBitCounter<I> {
pub fn new(mut words: I) -> Self {

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.

Now that this is public, two things about new.

  1. It has no rustdoc, and the zero-padding of the final word is an invariant the caller must uphold. bit_chunks().iter() without iter_padded() compiles, and the counter then reads the tail word as 0 via unwrap_or(0), so counts for positions in the last partial word are silently low. A constructor taking &BooleanBuffer (returning SetBitCounter<impl Iterator<Item = u64> + '_>) would remove that footgun and replace the six repeated SetBitCounter::new(x.bit_chunks().iter_padded()) call sites.
  2. The monotonic-position precondition is only a debug_assert!(end >= self.word_start), which does not catch a regression inside the current word. By reading, in a release build a call with end < word_start wraps end - self.word_start and the while loop does not terminate in practice. Please document it on the type, or use a cheap assert!. I did not run this.


/// Returns a bitmap whose bit `i` is set if row `i` of `list` contains a NULL
/// element, or `None` if the elements have no validity bitmap.
pub(crate) fn rows_with_null_elements<O: OffsetSizeTrait>(

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.

This body is the same as generic_list_row_has_nulls in datafusion/spark/src/function/array/array_contains.rs (list.values().nulls() then rows_have_nulls(nulls, offsets[0], offsets[1..])). A GenericListArray<O> variant next to rows_have_nulls in datafusion-common would serve both crates and drop both wrappers.

Also, nulls() returns Some for an all-valid bitmap, in which case this scans every word for nothing. Filtering on nulls.null_count() != 0 (O(1)) restores the old per-row null_count() != 0 short cut. The new benches only use None for no_nulls, so that case is not measured.

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.

The new benches only use None for no_nulls, so that case is not measured.

With None, rows_with_null_elements returns before calling rows_have_nulls, so the no_nulls cases in vector_math never run SetBitCounter. As I read the diff, their speedup comes from replacing the per-row values.slice(start, len) with &values.values()[start..end]. Could float_lists also build an all-valid NullBuffer as a third case? That would measure the path this comment is about, and the counter's cost when nothing is NULL.

let slice1 = values1.slice(start1, len1);
let slice2 = values2.slice(start2, len2);
if slice1.null_count() != 0 || slice2.null_count() != 0 {
let has_null =

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.

The has_null closure is rebuilt per row and does two bitmap lookups per row. The same code is in cosine_distance.rs. OR-ing the two bitmaps once before the loop (Option<BooleanBuffer> combined with &a | &b) gives one lookup per row and removes the duplicated closure in both files.

use std::sync::Arc;

#[test]
fn test_sliced_list() -> datafusion_common::Result<()> {

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.

Two points on the new tests.

  • Sibling UDFs name this test test_sliced_capacity (array_normalize, array_add, array_scale, remove). Please use the same name here and in cosine_distance.rs.
  • Both new tests pass the same array as both arguments, so an indexing mistake in the second bitmap (null_rows2) cannot fail them. inner_product.slt and cosine_distance.slt only cover NULL elements with single constant rows. A multi-row VALUES case with a NULL element only in the second argument, and one only in the first, would cover the per-row indexing and belongs in the .slt files.

@github-actions

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-common v55.1.0 (current)
       Built [  36.962s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.078s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [  36.479s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.077s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.716s] 229 checks: 228 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ExecutionOptions.enable_join_integer_prefilter in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:897
  field ExecutionOptions.join_integer_prefilter_max_key_range in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:897
  field ExecutionOptions.join_integer_prefilter_min_pruning_ratio in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:897
  field ExecutionOptions.join_integer_prefilter_sample_rows in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:897

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  75.989s] datafusion-common
    Building datafusion-functions-nested v55.1.0 (current)
       Built [  40.372s] (current)
     Parsing datafusion-functions-nested v55.1.0 (current)
      Parsed [   0.043s] (current)
    Building datafusion-functions-nested v55.1.0 (baseline)
       Built [  40.661s] (baseline)
     Parsing datafusion-functions-nested v55.1.0 (baseline)
      Parsed [   0.043s] (baseline)
    Checking datafusion-functions-nested v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.223s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  82.353s] datafusion-functions-nested
    Building datafusion-spark v55.1.0 (current)
       Built [  46.274s] (current)
     Parsing datafusion-spark v55.1.0 (current)
      Parsed [   0.076s] (current)
    Building datafusion-spark v55.1.0 (baseline)
       Built [  46.599s] (baseline)
     Parsing datafusion-spark v55.1.0 (baseline)
      Parsed [   0.081s] (baseline)
    Checking datafusion-spark v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.363s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  94.542s] datafusion-spark

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Oct 19, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.54902% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.92%. Comparing base (7e102d7) to head (6225dfc).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...afusion/spark/src/function/array/array_contains.rs 85.71% 0 Missing and 3 partials ⚠️
...afusion/spark/src/function/map/map_from_entries.rs 92.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #26507   +/-   ##
=======================================
  Coverage   82.92%   82.92%           
=======================================
  Files        1155     1155           
  Lines      458204   458274   +70     
  Branches   458204   458274   +70     
=======================================
+ Hits       379965   380041   +76     
+ Misses      55205    55192   -13     
- Partials    23034    23041    +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mbutrovich
mbutrovich self-requested a review October 21, 2026 17:22

@mbutrovich mbutrovich 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 @neilconway. The benchmarks for every call site make this easy to follow. I ran vector_math and array_contains on aarch64 (Apple M5 Max, default target CPU) against the base commit, and every case is faster, from 4.5% (array_normalize/width_256/no_nulls) to 80% (inner_product/width_32/null_elements).

sort_rows_with_nulls in array_sort still slices the element validity and counts it for each row (sort.rs). Should it use SetBitCounter too, or does the per-row sort cost hide the difference there?


/// Returns a bitmap whose bit `i` is set if row `i` of `list` contains a NULL
/// element, or `None` if the elements have no validity bitmap.
pub(crate) fn rows_with_null_elements<O: OffsetSizeTrait>(

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.

The new benches only use None for no_nulls, so that case is not measured.

With None, rows_with_null_elements returns before calling rows_have_nulls, so the no_nulls cases in vector_math never run SetBitCounter. As I read the diff, their speedup comes from replacing the per-row values.slice(start, len) with &values.values()[start..end]. Could float_lists also build an all-valid NullBuffer as a third case? That would measure the path this comment is about, and the counter's cost when nothing is NULL.

/// assert_eq!(counter.count_before(1), 1);
/// assert_eq!(counter.count_range(1, 5), 2);
/// ```
pub struct SetBitCounter<I> {

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.

Should this move to arrow-rs eventually? As far as I can tell, arrow-rs has no counterpart. Its closest APIs answer one range per call. Buffer::count_set_bits_offset builds a new UnalignedBitChunk on every call, which is the overhead this type avoids. BooleanBuffer::find_nth_set_bit_position answers the inverse question (where the n-th set bit is) and also rescans from start on every call. A counter like this one, or a kernel that takes an OffsetBuffer and returns per-row counts, would fit next to them in arrow-buffer. arrow-rs can ship an additive API like that in a minor release.

I'm happy to land it here first. Could you open an arrow-rs issue for the upstream API, and a DataFusion issue to switch to it once it lands? Because SetBitCounter becomes public in datafusion-common, switching later means a deprecation cycle. That makes the constructor question in @comphead's thread worth settling with the upstream API in mind. arrow-rs exposes its bitmap iterators as &self methods on BooleanBuffer (bit_chunks, set_indices, set_slices), so a constructor that takes &BooleanBuffer would also match how this would look upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change common Related to common crate functions Changes to functions implementation spark

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants