Skip to content

perf: contiguous slice fast path for binary-search kernels - #54

Merged
samukweku merged 5 commits into
mainfrom
issue-24-contiguous-fast-path
Aug 24, 2026
Merged

perf: contiguous slice fast path for binary-search kernels#54
samukweku merged 5 commits into
mainfrom
issue-24-contiguous-fast-path

Conversation

@samukweku

@samukweku samukweku commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 3 (final) of issue #24. The issue's own framing: "Evaluate a slice-based fast path for C-contiguous one-dimensional arrays while retaining the current strided-array behavior as a fallback... but should be adopted only if benchmarks and float semantics support them." This PR is that evaluation plus the resulting rollout, since the benchmark came back clearly positive.

  • All ten src/bin_search/*.rs files shared the same manual bisection over an ArrayView1<T> index (right[mid_idx as usize]). ArrayView1::as_slice() returns Some(&[T]) only when the view is contiguous in standard order; each kernel now branches once per call: when contiguous, std::slice::partition_point runs against the same [start, end) sub-range with a predicate matching the fallback's; when not, the original while loop is untouched as the fallback. Every existing post-loop check (sentinel/inverted-range rejection, defensive equality checks) is preserved exactly.
  • Public Python export names and signatures are unchanged — pure internal-implementation change.

Performance (decision gate)

Prototyped on binary_search_lt_core first (bench_bin_search_lt, extended with a strided fixture), benchmarked against the pre-change baseline before touching the other nine files:

old baseline new contiguous (fast path) new strided (fallback)
n=100 646.97 ns 473.67 ns (−26.8%) 649.71 ns (parity)
n=100,000 2.3530 ms 1.0374 ms (−55.9%) 2.4289 ms (parity)

Clear win, zero regression for non-contiguous input, so the same two-branch pattern was rolled out to the other nine kernels (bin_search_ge/gt/le, their _first siblings, and _ge_regions/_gt_regions) rather than adopting on bin_search_lt alone.

Correctness: what "fast path matches fallback" actually guarantees (updated after review)

An earlier version of this PR claimed fast-path/fallback parity unconditionally, including for a right containing NaN, and had a test using NaN embedded in right that happened to pass with that specific layout. That claim was wrong, caught in review, and is now corrected:

  • The predicate itself. ge's and gt's fast-path predicates were originally the algebraically-equivalent-for-ordered-values rewrite (<=/<) of the fallback's actual condition (!(current > left)/!(current >= left)). Those two forms agree for any totally-ordered comparison but diverge whenever either compared value is NaN. Fixed by writing the fast-path predicate as the literal negation of the fallback's condition instead of the algebraic rewrite, so a NaN query against an otherwise-sorted right now agrees between paths (in-contract input). lt/le never had this bug — their fallback conditions were already stated in the direction the fast path needs.
  • The search algorithm itself. Independent of the predicate, slice::partition_point is std's "branchless" binary search: it shrinks the search width by a fixed size / 2 every step regardless of the comparison outcome, unlike the fallback's comparison-driven width shrink. For a genuinely sorted right both converge on the same unique partition point regardless of which width-shrinking strategy got them there. For a right that itself contains NaN (which has no valid position in "sorted ascending" to begin with), the two can probe different elements and land on genuinely different, but never out-of-bounds, answers — confirmed with a standalone repro (right=[-1, NaN, 0, 1, 2, 3, 4], left=0: lt's fast path returns 3, its fallback returns 1) and affects all ten fast paths, not just ge/gt.
  • Considered and rejected: replacing partition_point with a manual loop over the &[T] slice, which would guarantee exact parity for any input. Benchmarked it: ~2.4x slower than partition_point at n=100,000 (954µs vs 2.26ms for lt's kernel) — most of the fast path's value comes from the branchless algorithm itself, not just slice bounds-check elision. Rewriting it away would give up most of what this PR is for, to guarantee a case already outside the documented precondition.

The corrected, accurate contract: right must be sorted ascending, which in particular means NaN-free. Within that precondition, fast path and fallback are guaranteed to agree (including for a NaN query against an otherwise-sorted right). Outside it (a right that itself contains NaN), neither path panics, but they are not guaranteed to agree with each other. This is now stated explicitly in the /// doc comment directly above every one of the 10 exported #[pyfunction]s (verified via __doc__/help() on a built wheel, not just the internal _core Rust functions — an earlier revision of this fix only documented the internal functions, invisible to Python callers).

Test plan

  • cargo test --no-default-features — 270 passed, 0 failed. Each of the 10 kernels gets:
    • A differential test proving fast path and fallback agree on ordinary contiguous-vs-strided input.
    • A differential test proving they agree when the query is NaN and right stays sorted (the in-contract NaN case).
    • A test using a right that itself contains NaN, asserting neither path panics and both return a valid (in-bounds-or-sentinel) result — explicitly not asserting parity, since that's exactly the guarantee this out-of-contract input doesn't have.
    • The 5 core-extracted kernels (bin_search_lt, and the four _first siblings) compare directly in pure Rust; the 5 wrapper-only kernels (ge/gt/le, the two _regions files) are compared via the actual #[pyfunction] through an embedded Python interpreter, building a non-contiguous PyReadonlyArray1 by slicing a Python-side numpy array with a step of 2.
  • cargo clippy --all-targets --all-features -- -D warnings — clean (including neg_cmp_op_on_partial_ord, deliberately suppressed at the 6 ge/gt sites where the negation is the fix, not the anti-pattern the lint normally flags)
  • cargo fmt --check — clean
  • cargo doc --no-default-features --no-deps — builds without warnings
  • Built the wheel via maturin develop --release and verified end-to-end: all 10 dtype-representative Python-facing functions return identical results for contiguous vs. strided input on ordinary data; the NaN-in-right repro reproduces the documented (no-panic, no-parity-guarantee) behavior through Python, not just the Rust core; every one of the 10 exported functions' __doc__/help() states the sorted-ascending/NaN-free precondition

Addresses the last of #24's three opportunities (one-pass *_first output, #46, merged; shared/validated comparison operator, #51, merged; this one). The "only rejects the exact -1 sentinel, not any negative value" bug class found along the way (same as what #52 fixed in comp.rs) was filed and fixed separately in #57/#60, then rebased in here rather than left for a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S1gZKDRiZoBLZnXXXjW3gt

Issue #24's third opportunity: evaluate a slice-based fast path for
C-contiguous 1-D arrays, retaining the strided-array loop as a
fallback, adopted only if benchmarks and float semantics support it.

All ten src/bin_search/*.rs files shared the same manual bisection
over an ArrayView1<T> index (right[mid_idx as usize]). Since
ArrayView1::as_slice() returns Some(&[T]) only when the view is
contiguous in standard order, each kernel now branches once per call:
when contiguous, std::slice::partition_point runs the identical
half-interval search (same predicate, same direction) a &[T] slice
gives the compiler bounds-check-elision and vectorization
opportunities a manual ArrayView1 index can't; when not, the original
while loop is untouched as the fallback. Every existing post-loop
check (sentinel/inverted-range rejection, defensive equality checks)
is preserved exactly, operating on whichever min_idx either branch
produced.

Benchmarked on binary_search_lt_core (bench_bin_search_lt, extended
with a strided fixture) against the pre-change baseline:
  n=100:      646.97ns -> 473.67ns contiguous (-26.8%), 649.71ns strided (parity)
  n=100,000:  2.3530ms -> 1.0374ms contiguous (-55.9%), 2.4289ms strided (parity)
Clear win with no regression for non-contiguous input, so the same
two-branch pattern is applied to all nine remaining kernels
(bin_search_ge/gt/le, their _first siblings, and _ge_regions/
_gt_regions) rather than adopting on bin_search_lt alone.

Each kernel gets a differential test proving its fast path and
fallback agree on identical logical content: the four core-extracted
_first kernels compare directly in pure Rust (mirroring
bin_search_lt's own new NaN-parity test -- NaN comparisons are always
`false` under the predicates used, so both paths must agree even on
input that isn't genuinely sorted); the five wrapper-only kernels
(ge/gt/le, the two _regions files, no core to extract cores for) are
compared via the actual #[pyfunction] through an embedded Python
interpreter, building a non-contiguous PyReadonlyArray1 by slicing a
Python-side numpy array with a step of 2.

Public Python export names and signatures are unchanged; this is a
pure internal-implementation change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3KAbkp6JV96KYXNc6EJmN
@samukweku
samukweku force-pushed the issue-24-contiguous-fast-path branch from 4050425 to 45cdebe Compare August 24, 2026 14:13
…nels

Review finding on PR #54's rebase: the contiguous-slice fast path used
predicates that are only algebraically equivalent to the fallback loop's
condition for totally-ordered values, not for NaN.

ge's fallback loop is `if current_value > left_value { max=mid } else {
min=mid+1 }` -- the "min side" predicate is the else branch, literally
`!(current_value > left_value)`. The fast path used `*v <= *left_value`
instead, assuming `!(a > b) == (a <= b)`, which only holds for totally
ordered types. For NaN, `a > b` is false (so `!(a > b)` is true) but
`a <= b` is also false -- the two predicates disagree, so partition_point
walks a different sequence of midpoints than the manual loop and can
return a completely different answer (confirmed repro: right=[1,2,3,4],
left=[NaN] -> contiguous path returns [-1], strided fallback returns [4]).
gt has the same issue: fallback's "min side" is `!(current_value >=
left_value)`, fast path used `*v < *left_value`.

lt/le don't have this bug: their fallback conditions are already stated
in the same direction the fast path needs (`current_value <= left_value`
/ `current_value < left_value` directly, no negation), so no algebraic
rewrite was needed there in the first place.

Fixes all 6 affected functions (both ge and gt, across the plain _core,
_first, and _regions shapes) by writing the fast-path predicate as the
literal negation of the fallback's condition -- `!(*v > *left_value)` for
ge, `!(*v >= *left_value)` for gt -- instead of the "cleaner" but
NaN-unsafe algebraic rewrite. This makes the two paths compute the exact
same boolean at every step, not just agree on sorted/non-NaN input.

clippy's neg_cmp_op_on_partial_ord lint flags exactly this kind of
negated-partial-order-comparison pattern (it exists for good reason --
this bug is a textbook example) but is suppressed at each of the 6 sites
with a comment explaining why the negation is deliberate here: it's what
makes the fast path match the fallback, not a careless shortcut.

Added a NaN-containing contiguous-vs-strided parity test to each of the 6
affected files (matching bin_search_lt.rs's existing test for the
already-safe case), verified each one fails against the pre-fix predicate
(confirmed by temporarily reverting bin_search_ge.rs's predicate and
re-running its new test: reproduces the exact [-1] vs [4] mismatch from
the review, then passes again once restored) and passes with the fix.
Also verified end-to-end via a built wheel with the exact repro
(right=[1,2,3,4], left=[NaN]): binary_search_ge_f64/binary_search_gt_f64
now agree between contiguous and strided input.

Also includes the cargo fmt fixes from the previous commit that were
run but never committed before pushing (the second, low-severity finding
from the same review) -- confirmed cargo fmt --check is now clean.
@samukweku samukweku self-assigned this Aug 24, 2026
…ight

Review finding on the current head: even with the predicate-negation fix
in the previous commit, partition_point and the manual fallback loop can
still land on different answers for a right array that itself contains
NaN, despite using an identical predicate.

Root cause (confirmed with a standalone repro): std's slice::partition_point
is a "branchless" binary search -- it shrinks the search width by a fixed
size/2 every step regardless of the comparison outcome. The manual
fallback loop shrinks by whatever the comparison decides (mid-min or
max-mid, generally unequal). For a genuinely sorted right both strategies
converge on the same unique partition point regardless of which one got
them there; for a right containing NaN (which has no valid position in
"sorted ascending" to begin with) the two can probe different elements
and land on genuinely different, but never out-of-bounds, answers.
Concretely: right=[-1, NaN, 0, 1, 2, 3, 4], left=0 -- lt's fast path
returns 3, its fallback returns 1.

Considered switching the fast path to a manual loop over the &[T] slice
instead of partition_point, which would guarantee exact parity for any
input. Benchmarked it: a manual loop over the slice is ~2.4x slower than
partition_point at n=100,000 (954us vs 2.26ms for lt's kernel) -- most of
the fast path's actual value comes from partition_point's branchless
algorithm, not just from slice bounds-check elision. Rewriting it away
would give up most of what this PR is for, to guarantee a case that's
already outside the function's documented "right is sorted ascending"
precondition (NaN cannot occupy a valid position in a sort order).

Fixes the actual bug: corrects the doc comments and inline ELI5s across
all 10 fast-path call sites (lt, lt_first, le, le_first, ge, ge_first,
ge_regions, gt, gt_first, gt_regions) that overclaimed "bit-for-bit
identical... for every input, not just totally-ordered ones" -- the
correct, narrower claim is that fast and fallback agree for the
documented in-contract case (right sorted, NaN-free), and neither panics
outside it, but they are not guaranteed to agree outside it.

Also fixes a real (not just theoretical) instance of this: bin_search_lt.rs
already had a test from before this session's involvement asserting fast
== fallback with NaN embedded directly in right, which happened to pass
by luck with that specific layout despite the claim being false in
general (per this same review's earlier observation about the NaN tests
this session added). Replaced it with two tests: one keeping the
in-contract shape (NaN as the query only, right stays sorted -- a real,
still-valid guarantee), and a new one using the review's exact
counterexample to honestly document the out-of-contract case (no panic,
no parity assertion). Replicated the same two-test split across all 10
files (20 tests total, some Python-wrapper-level for the _regions files
which have no separate _core).

Verified via cargo test (270 passed), clippy -D warnings and cargo fmt
--check both clean, and a rebuilt wheel reproducing the review's exact
finding end-to-end (lt: fast=[3] vs fallback=[1], no panic) across all
four operators.
…evel doc

Follow-up to the previous commit: that commit documented the NaN-parity
caveat thoroughly in inline implementation comments (near each
partition_point call) and in test comments, but not consistently in the
top-level /// doc comment that's actually attached to each exported
function -- the thing someone reads first (via source, or an IDE's hover
tooltip; these functions are on a private module so they don't appear in
a public `cargo doc` build, but the doc comments are still what a Rust
maintainer sees).

Audit before this commit: 5/10 files' top doc said "sorted ascending" but
not NaN-free specifically; 3/10 (le.rs, ge.rs, gt.rs) didn't mention
sortedness in their top doc at all (only malformed-range handling); 2/10
(ge_regions.rs, gt_regions.rs) had no top-level doc comment whatsoever.

Fixes all three gaps: added an explicit "right is assumed sorted
ascending, which in particular means NaN-free" clause (or a full new doc
comment, for the two _regions files that had none) to the top of every
one of the 10 files, each pointing to bin_search_lt.rs's core for the
full explanation of why NaN-free matters and what happens when it's
violated.

Verified: cargo test (270 passed, unchanged -- doc-only), clippy -D
warnings clean (including doc_lazy_continuation, which the previous
commit's edit to lt.rs's doc had accidentally triggered by shifting a
`>=` to start a wrapped line -- rustdoc's markdown parser reads a
line-leading `>` as a blockquote marker; reworded to avoid it), cargo fmt
--check clean, and cargo doc --no-deps builds without warnings.
…just _core

Review finding, verified by building the exact PR head and inspecting
__doc__: the previous commit's precondition doc comments landed on the
internal *_core Rust functions (e.g. binary_search_lt_core), not on the
#[pyfunction]-decorated functions PyO3 actually exposes to Python.
PyO3 only translates a /// doc comment into a Python __doc__ when it
sits directly above the #[pyfunction] item -- a doc comment on a plain
internal function has zero effect on what Python callers see.

Before this commit: binary_search_lt_f64.__doc__, binary_search_lt_first_f64
.__doc__, binary_search_ge_f64.__doc__ (and 5 more of the 10 exported
kernel families) were all None. Only bin_search_ge_regions.rs and
bin_search_gt_regions.rs got it right, because those two files had no
pre-existing _core doc-comment convention to inherit -- I wrote their
doc comments from scratch, directly above #[pyfunction], where the other
8 files already had an established (but Python-invisible) doc comment on
_core that I extended instead of duplicating.

Fixes all 8 affected files (lt, lt_first, le, le_first, ge, ge_first, gt,
gt_first) by adding a concise doc comment inside each macro_rules! body,
immediately above #[pyfunction], stating the sorted-ascending/NaN-free
precondition and pointing to the _core function's doc comment (Rust
source) for the full explanation. The _core doc comments from the
previous commit are left in place for Rust-side readers/maintainers;
this adds the Python-visible copy alongside them, it doesn't replace
anything.

Verified by rebuilding the wheel and checking __doc__ on all 10 exported
*_f64 functions (the float dtype, where NaN is actually possible) plus
help() output and one integer dtype variant for good measure -- all 10
now state the precondition; previously only 2 did. cargo test (270
passed, unchanged -- doc-only), clippy -D warnings, cargo fmt --check,
and cargo doc --no-deps all clean.
@samukweku
samukweku merged commit 83e14a7 into main Aug 24, 2026
17 checks passed
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