Skip to content

GH-55292: [C++][Dataset] Prune Parquet row groups for is_in predicates with large value sets - #55293

Draft
rok wants to merge 7 commits into
apache:mainfrom
rok:GH-55292-is-in-row-group-pruning
Draft

rok wants to merge 7 commits into
apache:mainfrom
rok:GH-55292-is-in-row-group-pruning

Conversation

@rok

@rok rok commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Rationale for this change

Since #46859, is_in predicates with more than 50 values are not simplified against Parquet row group statistics at all, so such filters read every row group. In addition, SimplifyWithGuarantee does not recognize the statistics guarantee of a row group that may contain nulls, (x >= min and x <= max) or is_null(x), so no filter prunes those row groups. See #55292 for the background. The is_in problem was reported and fixed in Polars in pola-rs/polars#29923.

What changes are included in this PR?

  • Sorted value set in the kernel state. For value sets with more than 50 values of an integer, floating point, temporal, duration, string or binary type, the is_in kernel state keeps the value set and sorts it (sort_indices + take, nulls dropped) the first time it is used for simplification. Plain kernel calls pay nothing extra. Sorting costs 1.5 to 3 times as much as building the kernel's lookup table, so value sets longer than 2^17 are only bounded by their smallest and largest value (min_max), which is cheap at any size and prunes as well when the values are clustered. Value sets containing NaN are not searched, since statistics cannot bound NaN.
  • Large is_in simplification. SimplifyWithGuarantee applies all inequality guarantees on a field to a large is_in call at once and looks up the admitted range with search_sorted. Using both row group bounds together is what lets us skip row groups that lie inside the hull of the value set but hold none of its values (for value sets of up to 2^17 values). If no value can match, the call is replaced by what it then evaluates to: false, invert(true_unless_null(x)) for EMIT_NULL and INCONCLUSIVE on nullable data, or a null literal for INCONCLUSIVE with a null in the value set. Otherwise the call is left unchanged, with no rebinding. Sets of up to 50 values keep using the existing filter-and-rebind path.
  • Nullable ranges. Guarantees of the form (cmp(x, a) and cmp(x, b)) or is_null(x) now yield both inequalities, and an is_valid(x) guarantee rules out nulls. Comparisons and small is_in sets now prune row groups with nulls too.
  • Casts on the filtered expression. Guarantee bounds are put through the same order preserving casts as the field. Strict bounds are weakened to inclusive ones through casts that may map distinct values to the same value (e.g. float64 to float32, int64 to float64). This also fixes the existing comparison and small is_in simplification: given f64 > 51, cast(f64, float32) == 51 was simplified to false although f64 = 51.00000001 casts to 51.
  • Comparison::StripOrderPreservingCasts no longer strips same-width unsigned to signed casts that may wrap (allow_int_overflow).
  • The small is_in path now honors the deprecated skip_nulls option (GetNullMatchingBehavior()).

Are these changes tested?

Yes. New expression tests: SimplifyIsInLargeValueSet, SimplifyIsInLargeValueSetNullMatching (which also checks that simplified filters evaluate like the originals on rows with nulls), SimplifyWithNullableRangeGuarantee, SimplifyComparisonThroughNonInjectiveCast, SimplifyIsInDeprecatedSkipNulls and SimplifyLargeIsInThenExecute. A new Parquet dataset test checks which row groups are read for large is_in filters under every null matching behavior, and for comparisons on row groups with nulls.

Scan timings on a 49.3M row file with 47 row groups of 2^20 rows and a sorted id column (release build, file in page cache, median of 5 scans, each binding a fresh filter). The value sets are shuffled distinct ids clustered in a few row groups:

is_in value set Row groups holding ids main This PR
100 ids 2 168 ms 11 ms
10K ids 2 179 ms 11 ms
100K ids 2 198 ms 22 ms
1M ids 2 421 ms 67 ms
10M ids 11 862 ms 582 ms
1K ids spread over all row groups 47 178 ms 177 ms

Are there any user-facing changes?

No API changes. is_in filters with more than 50 values prune Parquet row groups again (except for decimal, fixed size binary and view value sets, and dictionary columns). Range filters and small is_in filters now also prune row groups that contain nulls. Filters with explicit narrowing floating point casts or wrapping unsigned to signed casts could previously be simplified to false and skip matching rows; they no longer are.

Was AI used for this PR?

PR code and description written by:

  • Human
  • AI

Reviewed before submission by:

  • Human
  • AI
  • Not reviewed

The code was drafted with an AI assistant; I reviewed, tested and benchmarked it and iterated on the design.

@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #55292 has been automatically assigned in GitHub to PR creator.

rok added 4 commits November 3, 2026 18:12
… sets using a sorted value set

Since apacheGH-46777 is_in predicates with more than 50 values are not simplified
against row group statistics at all, because filtering the value set and
rebinding the call for every guarantee was prohibitively expensive.

Large value sets are now simplified with a binary search instead:

- The is_in kernel init keeps a copy of the value set sorted in the order of
  the comparison kernels, with nulls removed. An already sorted value set is
  reused as is after a single O(N) check; an unsorted one is sorted lazily and
  thread-safely on the first simplification, so eager kernel calls never pay
  for sorting. Value sets containing NaN are not searchable, since statistics
  cannot bound NaN.
- SimplifyWithGuarantee applies all inequality guarantees on a field to a
  large is_in call at once, narrowing an index range into the sorted value set
  with lower/upper bound searches. If the range is empty the call simplifies to
  false (subject to the null matching behavior), otherwise it is left
  unchanged. This is exact for any number of values, costs O(log N) per
  guarantee and never rebinds. Applying both bounds together is what allows
  pruning a row group inside the hull of the value set but holding none of its
  values, which no per-bound check can do.

Value sets up to 50 values keep using the existing filter-and-rebind path.
…d of via sort_indices + take

Sorting a copy of the values with std::sort is about 14x faster than the
generic sort_indices + take path for a shuffled set of 10M int64 values
(0.19s vs 2.6s). Nulls are dropped while copying and a set containing NaN is
reported as not searchable before sorting, since NaN would break the strict
weak ordering std::sort requires. Non-primitive types (binary, decimal) keep
using the generic path.
…in is_in pruning

Extract nullable min/max bounds from Parquet statistics for large value sets. Respect GetNullMatchingBehavior() in both simplification paths and when rebinding narrowed sets.

Add expression and Parquet regressions for nullable ranges and conflicting deprecated skip_nulls options.
…and prune nullable ranges in every simplification path

Inequality guarantees are now restated through the order preserving casts
wrapping a filter's argument before they are applied: strict bounds are
weakened to inclusive ones and the bound is put through the same casts,
since e.g. f64 = 51.00000001 satisfies f64 > 51 but casts to 51f. This
applies to plain comparisons and small is_in value sets as well as the
large value set path, which previously was the only one handling it.

The nullable range guarantee produced by Parquet statistics for row groups
with nulls, (x >= min and x <= max) or is_null(x), is now extracted for all
simplifications rather than only for large is_in value sets, and an
is_valid(x) member of the guarantee rules out nulls for the inequalities on
x.
@rok
rok force-pushed the GH-55292-is-in-row-group-pruning branch from a3f317c to 79750a7 Compare November 3, 2026 17:12
rok added 3 commits November 3, 2026 19:23
…L and INCONCLUSIVE calls

- Defer concatenating, checking and sorting the value set to the first
  GetSortedValueSet() call (std::call_once), so plain is_in kernel calls pay
  nothing extra at init.
- Simplify an is_in call without possible matches to its exact result for
  every null matching behavior: invert(true_unless_null(arg)) for EMIT_NULL
  and INCONCLUSIVE, or a null literal for INCONCLUSIVE with a null in the
  value set, so these prune row groups too.
- Factor out Comparison::IsOrderPreservingCast, derive
  IsSearchableValueSetType from the accessor trait, and drop unreachable
  NaN, type and comparison checks.
- Test chunked value sets and the new null matching results.
…pruning and condense tests

- Sort the value set with sort_indices + take and search it with
  search_sorted instead of hand-written visitors. Decimal and fixed size
  binary value sets are no longer searchable since search_sorted does not
  support them.
- Make the large is_in simplification tests table-driven, merge the null
  matching tests, and share a helper which checks that simplified filters
  evaluate like the originals.
- Merge the two Parquet row group pruning tests into one file whose row
  groups contain nulls.
…and max instead of sorting them

Sorting a value set costs 1.5-3x as much as building the is_in lookup
table, so for 10M shuffled ids the one-time sort outweighed the row groups
it pruned and scans got slower than without pruning. Value sets longer
than kIsInMaxSortedValueSet (2^17) are now only bounded by their smallest
and largest value (min_max), which is cheap at any size and prunes as well
when the values are clustered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant