Skip to content

perf: one-pass output for binary_search_*_first kernels - #46

Merged
samukweku merged 3 commits into
mainfrom
issue-24-bin-search-compare-perf
Aug 24, 2026
Merged

perf: one-pass output for binary_search_*_first kernels#46
samukweku merged 3 commits into
mainfrom
issue-24-bin-search-compare-perf

Conversation

@samukweku

@samukweku samukweku commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 1 of issue #24 (the three opportunities are independently scoped; this PR covers "One-pass *_first output" only).

  • binary_search_{lt,gt,ge,le}_first.rs each searched every row into a left.len()-sized Array1 using an internal "no match" marker (0 or right.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.
  • Extracts each into a plain-Rust *_first_core function (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-demand Vecs 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.
  • Public Python export names and signatures are unchanged (-> (Bound<PyArray1<i64>>, Bound<PyArray1<i64>>, i64)); this is a pure internal-implementation change.

Edit (follow-up commit 68f5130): the output Vecs were initially Vec::with_capacity(left.len()) (the exact upper bound, since at most one match per row). Review flagged that this eagerly reserves left.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 plain Vec::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) in benches/kernels.rs to 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:

bin_search_first allocation report (bytes / alloc count / peak):
  lt_first n=    100:      4032 bytes /  12 allocs /      2560 peak
  lt_first sparse n=    100:         0 bytes /   0 allocs /         0 peak
  lt_first n= 100000:   4194240 bytes /  32 allocs /   2621440 peak
  lt_first sparse n= 100000:         0 bytes /   0 allocs /         0 peak

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 Vec growth.

Old (Vec::with_capacity(left.len())) vs. new (Vec::new()), same process/run, by survival rate:

n survival old bytes/allocs/peak new bytes/allocs/peak old time new time
100 0% 1,600 / 2 / 1,600 0 / 0 / 0 489 ns 456 ns
100 10% 1,600 / 2 / 1,600 448 / 6 / 320 503 ns 559 ns
100 50% 1,600 / 2 / 1,600 1,984 / 10 / 1,280 595 ns 706 ns
100 100% 1,600 / 2 / 1,600 4,032 / 12 / 2,560 696 ns 864 ns
100,000 0% 1,600,000 / 2 / 1,600,000 0 / 0 / 0 1.999 ms 2.009 ms
100,000 10% 1,600,000 / 2 / 1,600,000 524,224 / 26 / 327,680 2.098 ms 2.129 ms
100,000 50% 1,600,000 / 2 / 1,600,000 2,097,088 / 30 / 1,310,720 2.249 ms 2.275 ms
100,000 100% 1,600,000 / 2 / 1,600,000 4,194,240 / 32 / 2,621,440 2.64 ms* 2.429 ms

* 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_capacity always 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 verifying left_index stays correctly paired with surviving rows, float dtype, one per file)
  • cargo clippy --all-targets --all-features -- -D warnings -- clean
  • cargo fmt --check -- clean
  • cargo bench --no-default-features --no-run -- compiles; ran bin_search_first and bin_search_first_old_vs_new groups directly, allocation/timing tables above
  • Built the wheel via maturin develop and ran all four operators plus the empty-input and float-dtype edge cases through the actual Python-facing functions end-to-end

Addresses part of #24 (One-pass *_first output). 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

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
@samukweku samukweku self-assigned this Aug 24, 2026
… 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.
@samukweku
samukweku merged commit e3184d0 into main Aug 24, 2026
16 checks passed
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
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