perf: contiguous slice fast path for binary-search kernels - #54
Merged
Conversation
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
force-pushed
the
issue-24-contiguous-fast-path
branch
from
August 24, 2026 14:13
4050425 to
45cdebe
Compare
…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.
…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.
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
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.
src/bin_search/*.rsfiles shared the same manual bisection over anArrayView1<T>index (right[mid_idx as usize]).ArrayView1::as_slice()returnsSome(&[T])only when the view is contiguous in standard order; each kernel now branches once per call: when contiguous,std::slice::partition_pointruns against the same[start, end)sub-range with a predicate matching the fallback's; when not, the originalwhileloop is untouched as the fallback. Every existing post-loop check (sentinel/inverted-range rejection, defensive equality checks) is preserved exactly.Performance (decision gate)
Prototyped on
binary_search_lt_corefirst (bench_bin_search_lt, extended with a strided fixture), benchmarked against the pre-change baseline before touching the other nine files: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_firstsiblings, and_ge_regions/_gt_regions) rather than adopting onbin_search_ltalone.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
rightcontaining NaN, and had a test using NaN embedded inrightthat happened to pass with that specific layout. That claim was wrong, caught in review, and is now corrected:ge's andgt'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-sortedrightnow agrees between paths (in-contract input).lt/lenever had this bug — their fallback conditions were already stated in the direction the fast path needs.slice::partition_pointis std's "branchless" binary search: it shrinks the search width by a fixedsize / 2every step regardless of the comparison outcome, unlike the fallback's comparison-driven width shrink. For a genuinely sortedrightboth converge on the same unique partition point regardless of which width-shrinking strategy got them there. For arightthat 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 justge/gt.partition_pointwith a manual loop over the&[T]slice, which would guarantee exact parity for any input. Benchmarked it: ~2.4x slower thanpartition_pointat n=100,000 (954µs vs 2.26ms forlt'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:
rightmust 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-sortedright). Outside it (arightthat 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_coreRust 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:rightstays sorted (the in-contract NaN case).rightthat 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.bin_search_lt, and the four_firstsiblings) compare directly in pure Rust; the 5 wrapper-only kernels (ge/gt/le, the two_regionsfiles) are compared via the actual#[pyfunction]through an embedded Python interpreter, building a non-contiguousPyReadonlyArray1by slicing a Python-side numpy array with a step of 2.cargo clippy --all-targets --all-features -- -D warnings— clean (includingneg_cmp_op_on_partial_ord, deliberately suppressed at the 6ge/gtsites where the negation is the fix, not the anti-pattern the lint normally flags)cargo fmt --check— cleancargo doc --no-default-features --no-deps— builds without warningsmaturin develop --releaseand 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-rightrepro 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 preconditionAddresses the last of #24's three opportunities (one-pass
*_firstoutput, #46, merged; shared/validated comparison operator, #51, merged; this one). The "only rejects the exact-1sentinel, not any negative value" bug class found along the way (same as what #52 fixed incomp.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