[PERF] Selective predicate selection for equi+non-equi joins - #1665
Draft
samukweku wants to merge 2 commits into
Draft
[PERF] Selective predicate selection for equi+non-equi joins#1665samukweku wants to merge 2 commits into
samukweku wants to merge 2 commits into
Conversation
…ui joins _get_indices_equi.py (joins combining an == predicate with non-equi predicates) consumes the same mapping["le_or_ge"]/["le_lt"]/["ge_gt"] as the pure non-equi dispatch tree, but #1658's _select_anchor and #1663's _maybe_select_better_range_bounds never reached it - same class of pathology, different dispatch path. Adds _maybe_select_better_equi_predicates in _get_indices_equi.py, mirroring both fixes: single-anchor case for _equi_not_range_join.py (reuses _le_ge_1_or_more._sample_candidate_cost directly, same as #1663), two-bound case for _equi_range_join.py. Scoped to keep in ('first', 'last'), matching #1658/#1663. _equi_uniq_join.py needs no fix and is correctly left untouched: it flattens every non-equi predicate into one unordered post-filter set (dict.fromkeys(rest)) regardless of which mapping key it came from, since the == predicate already reduces to at most one candidate per left row via a hash indexer - there's no anchor/window narrowing to optimize there in the first place. Verified this by reading the file before assuming the issue's original 3-call-site framing was complete; confirmed reordering mapping upstream of it is harmless (order- insensitive) either way, so applying the fix before the whole dispatch (not just the two paths that need it) is safe and simpler than special- casing which downstream function gets called first. Benchmark (equi join on a ~20-group column, plus 2 non-equi predicates with one much less selective than the other): n broad-first selective-first ratio 1,000 2.14ms 2.00ms 1.07x 3,000 2.56ms 2.13ms 1.20x 10,000 6.80ms 2.81ms 2.42x 30,000 40.38ms 4.51ms 8.95x After this fix, flat at ~1.00-1.06x across the same range.
Order within le_or_ge never matters where it's consumed as a post- filter set (both the range-join and single-anchor branches confirmed), so rebuilding a new list each time was more than necessary - an in-place swap says what's actually happening (promote one candidate, demote the other) more directly.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_get_indices_equi.py(joins combining an==predicate with non-equi predicates) consumes the samemapping["le_or_ge"]/["le_lt"]/["ge_gt"]as the pure non-equi dispatch tree, but [PERF] Selectivity-aware anchor predicate selection for conditional_join #1658's_select_anchorand [PERF] Selective bound selection for range joins #1663's_maybe_select_better_range_boundsnever reached it - same class of pathology (Issue [PERF] Optimize conditional_join anchor predicate selection #1641), different dispatch path._maybe_select_better_equi_predicatesin_get_indices_equi.py, mirroring both prior fixes: the single-anchor case for_equi_not_range_join.py(reuses_le_ge_1_or_more._sample_candidate_costdirectly, same helper [PERF] Selective bound selection for range joins #1663 reused), and the two-bound case for_equi_range_join.py.keep in ('first', 'last'), matching [PERF] Selectivity-aware anchor predicate selection for conditional_join #1658/[PERF] Selective bound selection for range joins #1663's own scoping._equi_uniq_join.pyneeds no fix, and is correctly left untouched - it flattens every non-equi predicate into one unordered post-filter set (dict.fromkeys(rest)) regardless of which mapping key it came from, since the==predicate already reduces to at most one candidate per left row via a hash indexer - there's no anchor/window narrowing to optimize there in the first place. The original issue text assumed all three consumers needed the fix; verified this by reading the file directly rather than assuming.mappingbefore_equi_uniq_join's try-block is harmless there too (order-insensitive), which is simpler than special-casing which downstream function runs first.ELI5
conditional_joinhas a fast path for joins that combine an equality condition (like matching on an id or category) with inequality conditions (like a date range). That fast path picks one of the inequality conditions to do the real narrowing and treats the rest as simple checks - and it had the same "always use whichever one you typed first" bug #1641/#1658/#1659 already fixed elsewhere, just in this different code path that nobody had gotten to yet. This PR applies the same fix here too.Benchmark
Equi join on a ~20-group column, plus 2 non-equi predicates (one much less selective than the other), before this fix:
After this fix, flat at ~1.00-1.06x across the same range.
Test plan
pixi run pytest tests/functions/test_conditional_join.py -v -n auto- 294 passed, 1 skipped (full existing suite, unchanged)_equi_not_range_join.py) and equi+range (_equi_range_join.py) dispatch paths, acrosskeep='first'/'last'; a mock-based guard proving the selection is never invoked forkeep='all'; a no-op check for the single-candidate case (the common case).pixi run pytest --doctest-modules janitor/functions/conditional_join.pypixi run lintDraft PR: depends on the unmerged #1658/#1663 chain (based on
issue-1659-range-join-anchor, which is based onissue-1641-anchor-selection) - not mergeable until those land first.🤖 Generated with Claude Code