perf: one-pass output for binary_search_*_first kernels - #46
Merged
Conversation
binary_search_{lt,gt,ge,le}_first previously searched every row into a
left.len()-sized Array1 using an internal "no match" marker (0 or
right.len(), whichever value that operator's search structurally can't
produce as a genuine result), then made a second pass over that array to
copy only the surviving rows into exactly-sized output arrays.
Extracts each into a plain-Rust *_first_core function (matching the #21
extraction pattern) that pushes (search index, left_index[i]) straight
into two Vec::with_capacity(left.len())s as soon as a row is known to
match, instead of writing a full-length array and filtering it
afterward. No marker value needed since a non-matching row is simply
never pushed.
Measured with a custom #[global_allocator] wrapper in benches/kernels.rs:
the one-pass version does exactly 2 allocations per call (one per output
Vec), each sized exactly right (confirmed at n=100 and n=100,000, worst
case where every row survives). The old shape needed up to 4.
Public Python export names and signatures are unchanged; this is a pure
internal-implementation change. Addresses the "One-pass *_first output"
opportunity from issue #24 -- the shared/validated comparison-operator
enum and the contiguous-array fast path are the issue's other two
opportunities, tracked separately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1gZKDRiZoBLZnXXXjW3gt
5 tasks
… survival rates The PR #46 description still cited pre-fix (Vec::with_capacity) allocation numbers after commit 68f5130 switched binary_search_lt_first_core to grow-on-demand Vec::new() output vectors, and had no evidence for how the two strategies compare on dense (high-survival) input where the new approach pays for reallocations the old one avoided. Adds a bench-only copy of the pre-fix with_capacity implementation and a SurvivalFixture (0/10/50/100% match rate, interleaved), then reports bytes/allocs/peak memory plus criterion timing for both implementations side by side in the same run. Also extends CountingAllocator to track live/peak bytes, not just cumulative bytes and call count.
8 tasks
samukweku
added a commit
that referenced
this pull request
Aug 24, 2026
All 16 files under src/compare/ carried their own private copy of binary_compare, an i8/i64 numeric-code match for `>`, `>=`, `<`, `<=`, `==`, `!=`. An unrecognized code silently fell through to `!=` instead of being rejected, and every file re-decoded the operator on every single candidate pair inside the innermost loop instead of once per call. Adds src/compare/op.rs: a shared CompareOp enum with try_from_code (validates 0..=5, raises a clear PyValueError otherwise, mirroring aggs::ensure_tape_width's existing error pattern) and comparator() (picks the comparison once as a plain function pointer, so the per-candidate loop calls it directly instead of re-matching on the operator every time). Every compare file now decodes with CompareOp::try_from_code(op)? once near the top of the function, before the loop, and returns PyResult instead of a bare tuple so the validation error can propagate. Public Python export names and signatures are otherwise unchanged -- op stays i8 in 14 files and i64 in the 2 comp_no_range* files, and valid codes behave identically to before. comp.rs's existing compare_start_end_core (extracted in #21/#28) now takes CompareOp directly instead of a raw op code; its tests and benches/kernels.rs::bench_compare_start_end updated accordingly, and bench_support re-exports CompareOp. Addresses the "shared, validated comparison operator" opportunity from issue #24 -- the one-pass *_first output (#46) and the contiguous fast-path are the issue's other two opportunities, tracked separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3KAbkp6JV96KYXNc6EJmN
samukweku
added a commit
that referenced
this pull request
Aug 24, 2026
All 16 files under src/compare/ carried their own private copy of binary_compare, an i8/i64 numeric-code match for `>`, `>=`, `<`, `<=`, `==`, `!=`. An unrecognized code silently fell through to `!=` instead of being rejected, and every file re-decoded the operator on every single candidate pair inside the innermost loop instead of once per call. Adds src/compare/op.rs: a shared CompareOp enum with try_from_code (validates 0..=5, raises a clear PyValueError otherwise, mirroring aggs::ensure_tape_width's existing error pattern) and comparator() (picks the comparison once as a plain function pointer, so the per-candidate loop calls it directly instead of re-matching on the operator every time). Every compare file now decodes with CompareOp::try_from_code(op)? once near the top of the function, before the loop, and returns PyResult instead of a bare tuple so the validation error can propagate. Public Python export names and signatures are otherwise unchanged -- op stays i8 in 14 files and i64 in the 2 comp_no_range* files, and valid codes behave identically to before. comp.rs's existing compare_start_end_core (extracted in #21/#28) now takes CompareOp directly instead of a raw op code; its tests and benches/kernels.rs::bench_compare_start_end updated accordingly, and bench_support re-exports CompareOp. Addresses the "shared, validated comparison operator" opportunity from issue #24 -- the one-pass *_first output (#46) and the contiguous fast-path are the issue's other two opportunities, tracked separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3KAbkp6JV96KYXNc6EJmN
5 tasks
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 1 of issue #24 (the three opportunities are independently scoped; this PR covers "One-pass
*_firstoutput" only).binary_search_{lt,gt,ge,le}_first.rseach searched every row into aleft.len()-sizedArray1using an internal "no match" marker (0orright.len(), whichever value that specific operator's search structurally can never produce as a genuine result), then made a second pass over that array to copy only the surviving rows into exactly-sized output arrays.*_first_corefunction (matching the [MAINT] Add direct correctness tests and benchmarks #21/[MAINT] Add direct correctness tests and benchmarks #28 extraction pattern already used elsewhere) that pushes(search index, left_index[i])straight into two grow-on-demandVecs as soon as a row is known to match, instead of writing a full-length array and filtering it afterward. No marker value needed at all, since a non-matching row is simply never pushed.-> (Bound<PyArray1<i64>>, Bound<PyArray1<i64>>, i64)); this is a pure internal-implementation change.Edit (follow-up commit
68f5130): the outputVecs were initiallyVec::with_capacity(left.len())(the exact upper bound, since at most one match per row). Review flagged that this eagerly reservesleft.len()space for both outputs even when most/all rows are dropped, so sparse/no-match inputs would allocate as much as the old marker array before discovering nothing survives. Switched to plainVec::new()(grow-on-demand) instead -- see the corrected Performance section below, which replaces the original (now-stale) with_capacity-only numbers.Performance
Added a custom
#[global_allocator]wrapper (count_allocations) inbenches/kernels.rsto report allocation counts and peak live bytes alongside criterion's timing, since criterion itself only measures wall time.Current implementation (
Vec::new(), grow-on-demand), single call:Sparse (no-row-survives) input now allocates nothing at all -- the case the with_capacity version couldn't avoid. Dense (all-rows-survive) input now costs more allocation calls and more peak bytes than with_capacity's flat 2 allocations, because of geometric
Vecgrowth.Old (
Vec::with_capacity(left.len())) vs. new (Vec::new()), same process/run, by survival rate:* noisy: criterion only completed 210 iterations in the 500ms window at this size/config and flagged 20% outliers; treat as approximate, not the old implementation reliably losing here.
Reading: memory-wise the trade is real and exactly what you'd expect --
with_capacityalways pays a flat 1.6 MB-at-n=100k regardless of match rate;Vec::new()scales with actual survivors, down to zero for an all-miss column, but pays more allocation calls and a higher peak (roughly 1.3-1.6x the final buffer size, from geometric growth plus a transient old+new buffer overlap during the last doubling) once most/all rows match. Wall-time-wise the two are within noise of each other at every survival rate tested here -- no measured case shows a clear, reproducible regression from the allocator change. This isn't a rigorous benchmark (sample_size(20), 500ms measurement window, vs. this repo's usual 100 samples/5s, to keep the 16-combination sweep fast) -- treat the timing column as directional, not a tight confidence interval.Test plan
cargo test --no-default-features-- 165 passed, 0 failed (18 new tests: empty input, boundary/duplicate values, mixed match/no-match rows verifyingleft_indexstays correctly paired with surviving rows, float dtype, one per file)cargo clippy --all-targets --all-features -- -D warnings-- cleancargo fmt --check-- cleancargo bench --no-default-features --no-run-- compiles; ranbin_search_firstandbin_search_first_old_vs_newgroups directly, allocation/timing tables abovematurin developand ran all four operators plus the empty-input and float-dtype edge cases through the actual Python-facing functions end-to-endAddresses part of #24 (One-pass
*_firstoutput). The shared/validated comparison-operator enum and the contiguous-array fast path are the issue's other two opportunities, tracked separately.🤖 Generated with Claude Code
https://claude.ai/code/session_01S1gZKDRiZoBLZnXXXjW3gt