Skip to content

[PERF] Selective bound selection for range joins - #1663

Draft
samukweku wants to merge 14 commits into
issue-1641-anchor-selectionfrom
issue-1659-range-join-anchor
Draft

[PERF] Selective bound selection for range joins#1663
samukweku wants to merge 14 commits into
issue-1641-anchor-selectionfrom
issue-1659-range-join-anchor

Conversation

@samukweku

Copy link
Copy Markdown
Collaborator

Summary

ELI5

conditional_join supports range joins - two conditions together, like a > lo and a < hi, that box a value into a range. If you give it more than one candidate for either side (say, two different <-type conditions that could both act as the upper bound), it always used whichever one you happened to type first to do the real narrowing, even if that one wasn't the useful one - the exact same mistake #1641/#1658 already fixed for simpler joins, just showing up again here in a different code path. This PR applies the same fix: pick the genuinely useful candidate for each bound, not just the first one typed, independently for the lower and upper bound.

Benchmark

Two eligible <-type candidates for the upper bound, one much less selective than the other (bounded skew - deliberately not a domain-wide "always true" bound, since that causes a real, less-representative O(n²) blowup rather than a realistic pathology):

join_algorithm="default":

n broad-first narrow-first ratio
1,000 2.28ms 1.29ms 1.77x
3,000 6.12ms 1.64ms 3.73x
10,000 23.04ms 3.23ms 7.14x
30,000 86.22ms 7.57ms 11.39x
100,000 321.69ms 32.46ms 9.91x

join_algorithm="regions" shows the same shape, roughly 2.1x-7.4x over the same range (worse constant factor than default, consistent with #1660's finding that regions' asymmetric two-field construction generally carries a bigger tax).

After this fix, both algorithms hold flat at ~0.97-1.10x from 1k to 100k rows.

Test plan

  • pixi run pytest tests/functions/test_conditional_join.py -v -n auto - 288 passed, 1 skipped (full existing suite, unchanged)
  • New tests: _maybe_select_better_range_bounds picks the genuinely selective candidate (direct unit test, not just output equality); order-invariance for both the le_lt-side and the symmetric ge_gt-side, across keep='first'/'last' and both join_algorithm values; a mock-based guard proving the selection is never invoked for keep='all'; a no-op check confirming single-candidate range joins (the overwhelmingly common case) are completely unaffected.
  • pixi run pytest --doctest-modules janitor/functions/conditional_join.py
  • pixi run lint

🤖 Generated with Claude Code

…irst/last

The non-Numba multi-predicate conditional_join path always used the first
supplied <,<=,>,>= predicate as the binary-search anchor, regardless of
selectivity. A broad predicate picked as anchor over a selective one could
be 2x-77x slower (issue's own repro, reproduced here as 2x/8.8x/57x at
1k/3k/10k rows).

For keep='first'/'last', output is provably invariant to anchor choice:
the match set doesn't depend on which predicate narrows the window first,
and the Rust index builders do a true min/max reduction over the matching
window rather than picking by scan order. So _select_anchor evaluates
every le/ge candidate's window cost and picks the cheapest, with a stable
tie-break to avoid changing behavior on already-good orderings.

keep='all' is left untouched: which column right gets sorted by (i.e. the
anchor) determines output row order for that mode, so anchor choice can't
be safely reordered there without an observable behavior change. Filed as
a separate follow-up: #1657.

Also fixes an unrelated crash found while adding coverage: multi-predicate
keep='last' joins with a single </<= window called
index_starts_only_keep_last without its required counts argument.

Issue #1641
- _le_ge_1_or_more.py: keep the empty-index array local to _get_indices,
  matching the convention in sibling files, instead of a shared module-
  level constant.
- test_conditional_join.py: add order-invariance coverage across all 3!
  permutations of 3 le/ge predicates (not just 2), since _select_anchor
  picks among however many candidates are supplied.

Prompted by an independent review of #1658 that stress-tested the core
invariance claim (ties, nulls, 3-predicate cases, 360+ permutation
comparisons) and found no correctness break, but flagged these two as
worth cleaning up before merge.
…ction

# Conflicts:
#	CHANGELOG.md
#	tests/functions/test_conditional_join.py
Add a Core Principle and a dated Learned Patterns entry per the user's
request: every PR should get a fresh-context review pass (e.g. a
subagent with no memory of how the change was built) before being
treated as done, since implementer bias won't catch what an independent
read will - as demonstrated on PR #1658 itself.
…nchor

best[0] only meant "cost" because of where it was placed in a tuple
literal two lines below - not readable without cross-referencing the
construction site. Track best_cost/best_pos/best_result as separate
named locals instead; same tie-break semantics (strict <, first-seen
wins on ties).

Also adds a one-line docstring to _get_indices, which never had one
(pre-existing gap, not something this PR introduced) - it only tripped
interrogate's 55% threshold now because this commit's diff is scoped to
a single file instead of several.
_dual_non_equi.py (the region-number algorithm backing join_algorithm=
"regions" and range joins) linked the paper by bare URL only. Expand to
a full citation with DOI, and surface the same reference in
conditional_join's public docstring so it's discoverable from the
rendered API docs, not just a source comment.

Prompted by tracing #1641/#1658 back to this paper while investigating
whether "regions" mode was already immune to the anchor-ordering bug -
it isn't (see #1660), but the paper is directly relevant prior art for
both that and #1659.
Replace exact-cost anchor selection (evaluate every candidate's real
binary search, pick the cheapest) with a fixed-size random sample per
candidate. Correctness is unaffected either way: for keep='first'/'last',
output is provably invariant to which predicate anchors (see #1641), so
a suboptimal sample-based pick only costs performance, never output.

This fixes a real regression the exact version had: evaluating every
candidate meant its own cost scaled with input size, so on inputs where
the first-supplied predicate was already optimal, the exact version was
measurably slower than doing nothing - up to ~1.86x at 300k rows, ~3
seconds of pure waste at 10M rows. Sampling decouples the selection cost
from input size entirely: benchmarked at 0.99-1.00x (no measurable
regression) from 1M to 10M rows, versus 1.4-1.86x for the exact version,
while still correctly detecting and fixing the original pathological
case (broad-vs-selective predicate order).

Also fixes two bugs found while reviewing an in-progress draft of this
same idea:
- The sort-permutation-based reorder path assumed right's index was
  unique and a contiguous RangeIndex; get_indexer raises
  InvalidIndexError on any duplicate index. Removing the exact-and-reuse
  design entirely (in favor of sampling to choose, then a single plain
  call to _evaluate_le_ge_candidate) sidesteps this class of bug rather
  than patching it - unreachable via the public API today, since
  conditional_join resets both frames' indices before this code runs,
  but not something worth relying on staying true.
- A shared/module-level RNG would have made anchor choice - and
  therefore performance, though never output - silently vary across
  otherwise-identical calls to the same query. Each call now seeds its
  own RNG.
…hoice

Prior tests (order-invariance, "determinism") only asserted output
equality, which holds regardless of which candidate anchors by
construction - they couldn't distinguish correct selectivity-driven
selection from a coin flip. Fixed their docstrings to stop overclaiming
what they verify, and add direct unit coverage that inspects
_select_anchor's actual return value:

- picks the genuinely selective candidate, not just "some" candidate
- picks the same logical predicate regardless of argument order
- picks the same candidate across repeated calls (real determinism,
  not just output equality)
- _sample_candidate_cost computes the exact expected window for all
  four operators, against hand-computed values
- documents, with a deterministic repro, the known limitation that a
  fixed 1024-row sample can miss a rare-but-decisive feature (~36%
  miss probability for a feature present in 0.1% of rows -
  0.999**1024) - correctness is unaffected either way, only anchor-
  choice quality

Also found and fixed a bug in the two new selectivity-sensitive tests
themselves while writing them: the existing _dual_le_ge_frames helper
(used by the output-invariance tests) doesn't guarantee any real
selectivity skew between its "broad" and "selective" columns - both
sides of each column are drawn from similar-scale ranges, so which one
is actually more selective is close to a coin flip per seed. Added
_skewed_broad_selective_frames, which deliberately shifts one column's
range to guarantee a real skew, for the tests that need to assert
*which* candidate wins.
Add an ELI5 comment on _SAMPLE_SIZE - it's a reasonable round default
(big enough to reliably tell "broad" from "selective", small enough to
cost nothing even at huge n), not a value tuned against a benchmark
sweep.
…anchor

Per adversarial review: cost < best_cost relies on cost never being NaN
(a NaN comparison is always False, so it would silently pin candidate 0
regardless of later candidates - wrong anchor, never wrong output).
Currently unreachable, since inputs are guaranteed non-null/non-empty
by the time this runs, but worth flagging in case either guarantee
moves.
Formalizes a pattern already used in practice (PR #1644, issues #1641/
#1660): for changes involving non-obvious algorithmic or performance
reasoning, include a plain-language ELI5 section between the technical
summary and any benchmark numbers. Deliberately scoped to "non-obvious
changes only" rather than every PR/issue, to avoid templating trivial
changes.
…nd rows

Prompted by #1660: a ratio that looked like it might fade out below
30k rows instead held flat at ~1.8-1.9x all the way to 50M. Document
extending Benchmark/Measured impact tables far enough to show whether
an effect shrinks away or plateaus at scale.
…pot each

Several rules were stated verbatim in 3-5 separate places - the same
over-templated-file problem the project's own best-practices guidance
warns about (rules get lost when the file is padded with restatements):

- markdownlint: was in Core Principles, the Markdown Linting commands
  section, Anti-Patterns DON'T/DO, and a Learned Patterns entry. Kept
  Core Principles (the rule) + Markdown Linting (the how-to); dropped
  the rest.
- pixi usage: was in Development Environment (full detail) and
  Anti-Patterns DON'T/DO (verbatim restatement). Kept Development
  Environment.
- notebook conversion: was in Notebook Commands (with the CRITICAL
  warning) and Anti-Patterns DON'T/DO. Kept Notebook Commands.
- Adversarial review: full rule in Core Principles, near-identical
  restatement in Learned Patterns (both added in the same session).
  Kept Core Principles.
- "build docs in the docs environment": folded the Learned Patterns
  entry's useful "why" into Documentation Commands directly instead of
  living as a separate breadcrumb saying the same thing.

Common Anti-Patterns to Avoid shrinks to only the items that add
information not already stated in full elsewhere (DataFrame mutation,
tests, docstrings). Net -55 lines, no content lost - just one home per
rule instead of several.
_separate_conditions_based_on_op picked one <-type predicate for le_lt
and one >-type predicate for ge_gt via simple first-encountered-wins,
same class of bug #1641/#1658 fixed for same-direction predicates - just
one level removed, and feeding both join_algorithm values ('default'
and 'regions') since they both consume mapping["le_lt"]/["ge_gt"] from
this one function.

Adds _maybe_select_better_range_bounds in _get_indices_non_equi.py,
called after null-stripping (needed since _sample_candidate_cost
assumes null-free inputs) and before dispatch to either range-join
algorithm. Reuses _le_ge_1_or_more._sample_candidate_cost directly - no
new sampling logic, no new Rust. le_lt and ge_gt are independent
selection problems, not a joint one: both range-join algorithms always
process ge_gt first and le_lt second regardless of which candidate
either slot holds, so there's no "which bound is primary" question to
answer, just two separate #1641-shaped picks.

Scoped to keep in ('first', 'last'), mirroring #1658's original scoping
- keep='all' extension can follow the same #1657 pattern as a scoped
follow-up if wanted.

Benchmark (join_algorithm=default, bounded skew - a much-less-selective
extra <-type candidate, not a domain-wide one which causes real O(n^2)
blowup rather than a representative pathology):

  n         broad-first   narrow-first   ratio
  1,000        2.28ms         1.29ms     1.77x
  3,000        6.12ms         1.64ms     3.73x
  10,000      23.04ms         3.23ms     7.14x
  30,000      86.22ms         7.57ms    11.39x
  100,000    321.69ms        32.46ms     9.91x

join_algorithm=regions shows the same shape, roughly 2.1x-7.4x over the
same range. After this fix, both algorithms hold flat at ~0.97-1.10x
from 1k to 100k rows.
@samukweku
samukweku marked this pull request as draft August 21, 2026 06:56
samukweku added a commit that referenced this pull request Aug 21, 2026
…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.
@samukweku
samukweku force-pushed the issue-1641-anchor-selection branch from 5939326 to 142e493 Compare August 26, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant