Skip to content

[PERF] Extend selective anchor selection to keep='all' - #1662

Draft
samukweku wants to merge 15 commits into
issue-1641-anchor-selectionfrom
issue-1657-keep-all-anchor
Draft

[PERF] Extend selective anchor selection to keep='all'#1662
samukweku wants to merge 15 commits into
issue-1641-anchor-selectionfrom
issue-1657-keep-all-anchor

Conversation

@samukweku

Copy link
Copy Markdown
Collaborator

Summary

ELI5

conditional_join's multi-predicate path picks one condition to do the heavy lifting (the "anchor") and checks the rest more simply within that narrowed-down set. #1658 fixed a bug where, for keep='first'/'last', it always used whichever condition you happened to type first as the anchor - even if that one wasn't the useful one - which could make a query up to ~77x slower purely from argument order.

That fix was scoped to keep='first'/'last' only, because for keep='all' the anchor also determines what order the results come back in, and changing that felt like it needed more care. Turns out keep='all' had the same first-typed-wins bug the whole time, just never fixed - and it's worse: unlike keep='first'/'last' (which tops out around ~1.8-2x slower even in the worst case), keep='all''s slowdown kept growing the bigger the data got, with no ceiling in the sizes tested - a query that took 13ms with a good predicate order took over 10 seconds with a bad one, at only 100k rows. This PR applies the same fix here: pick the actually-useful condition instead of just the first one typed.

Benchmark

Broad-vs-selective scenario from #1641/#1658, keep='all' this time, before this fix:

n broad-first selective-first ratio
1,000 1.86ms 0.85ms 2.18x
3,000 9.51ms 1.08ms 8.84x
10,000 99.29ms 1.85ms 53.66x
30,000 916.34ms 3.90ms 235.14x
100,000 10,457.91ms 13.07ms 799.86x

Unlike keep='first'/'last' (which plateaus around ~1.8-1.9x once fixed - see #1660's table for the same shape of measurement on a different code path), this grows without bound across every size tested - I didn't push further than 100k rows before-fix since a single broad-first call was already over 10 seconds there.

After this fix, from 100k up to 10M rows:

n broad-first selective-first ratio
100,000 12.52ms 12.55ms 1.00x
1,000,000 157.71ms 154.88ms 1.02x
10,000,000 3,553.45ms 3,566.68ms 1.00x

Test plan

  • pixi run pytest tests/functions/test_conditional_join.py -v -n auto - 282 passed, 1 skipped (full existing suite, unchanged)
  • New tests: _select_anchor is now actually invoked for keep='all' (mock-based, wraps= the real implementation so it still runs); content-invariance across all 4 operators for keep='all' (sorted comparison, matching this file's existing keep='all' test pattern); a deterministic tie-case construction proving row order genuinely can differ between argument orders (while content never does) - built by finding a case where both candidates' sampled costs tie exactly, since that's the only condition under which this was found to actually happen (30 seeds of realistically-skewed data never produced a row-order difference - the sampling reliably picks the same logical predicate regardless of argument order when there's a real selectivity gap to detect).
  • 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.
_select_anchor (from #1658) previously only ran for keep='first'/'last',
leaving keep='all' on the original first-supplied-predicate anchor. That
turned out to be a real, severe, unbounded pathology - worse than the
one #1641/#1658 fixed for keep='first'/'last':

  n        broad-first   selective-first   ratio
  1,000       1.86ms          0.85ms        2.18x
  3,000       9.51ms          1.08ms        8.84x
  10,000     99.29ms          1.85ms       53.66x
  30,000    916.34ms          3.90ms      235.14x
  100,000  10457.91ms         13.07ms      799.86x

Unlike keep='first'/'last' (which plateaus around ~1.8-1.9x once the
sampling-based fix is in place, per #1658/#1660's benchmarks), this
grows without bound in the row counts tested - broad-first at 100k rows
alone took over 10 seconds.

After this change, ratio is flat at ~1.0-1.06x from 1k to 10M rows.

Row content (which rows appear, their values) is provably invariant to
anchor choice for every keep mode - only keep='all' row ORDER can be
affected, and that was never documented or guaranteed. Verified this
is a real, not just theoretical, trade-off: constructed a deterministic
tie case (both candidates' sampled costs equal) where row order does
differ between argument orders, and confirmed content stays identical
regardless via the same sorted-comparison pattern the rest of this file
already uses for keep='all' tests.

Note on methodology: an earlier attempt to benchmark this concluded
keep='all' had "no real pathology" - that conclusion was wrong, caused
by a shell cwd reset silently pointing `pixi run python <script>` at
the wrong worktree's environment between tool calls, not a property of
the code. Re-verified with explicit per-call timing and output-length
assertions in a single, carefully-scoped invocation before trusting the
numbers above.
Per adversarial review of #1662: this comment predated the fix in this
same PR and said anchor choice "must remain fixed (first-supplied)" for
keep='all' - no longer true, and directly contradicted by the six
keep='all' tests immediately below it.
@samukweku samukweku self-assigned this Aug 21, 2026
@samukweku
samukweku requested a review from ericmjl August 21, 2026 06:55
@samukweku
samukweku marked this pull request as draft August 21, 2026 06:56
@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