Skip to content

Scan selection candidates in place (ABI 42 -> 43) - #323

Merged
Alek99 merged 5 commits into
mainfrom
alek/mem-audit-2-abi
Jul 27, 2026
Merged

Scan selection candidates in place (ABI 42 -> 43)#323
Alek99 merged 5 commits into
mainfrom
alek/mem-audit-2-abi

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

Contains a C-ABI change: ABI_VERSION 42 -> 43. From the second memory audit
pass (follow-up to #309); sibling PRs carry the encode paths (#320), the Python
paths (#321), the Rust core (#322), and the legend cache.

The problem: partial selections cost more than total ones

select_range prunes by zone map before it scans, and then threw the saving
away. The pruned branch gathered the candidate rows into fresh columns purely so
it could call range_indices, which only knows how to walk a whole column:

candidates = _expand_zone_chunks(t.x, candidate_chunks)
out[t.id] = kernels.range_indices(
    t.x.values[candidates], t.y.values[candidates], lo_x, hi_x, lo_y, hi_y
)
out[t.id] = candidates[out[t.id]]

Two f64 gathers, 16 bytes per candidate, which produces a backwards result — on a
10M-row scatter:

window rows selected peak
half domain (pruned branch) 5,003,285 134.6 MB
whole domain (unpruned branch) 10,000,000 38.1 MB

Selecting half the trace cost 3.5x what selecting all of it cost.

The fix

xy_range_indices_rows is the rectangular twin of the lasso's
xy_polygon_select, which already takes a rows argument: same
(x, y, len, rows, n_rows, ..., out) shape, the same inclusive comparison chain
as range_scan_scalar, and the same order-preserving gather-down across worker
segments. The pruned branch reads through the row ids and gets canonical ids
straight back, so both gathers and the candidates[hits] re-index are gone.

The half-domain case drops to 57.6 MB. The whole-domain case is untouched.

It is a CPU win too. I had hedged this as "probably, but inside the local noise
band"; CodSpeed measured it cleanly in simulation mode:

benchmark base head
test_select_box_zone_pruned_1m 3.5 ms 1.7 ms x2.1

102 benchmarks untouched, 0 regressions. Removing two f64 gathers over the
candidate set removes the memory traffic that dominated that path.

ABI_VERSION is bumped 42 -> 43 in src/lib.rs and python/xy/_native.py
together, per the contributor contract.

Note for whoever merges second: #319 also claims 43. Whichever of the two
lands first takes it and the other needs renumbering — git will not flag this,
because a matching-but-wrong pair of constants merges cleanly.

Why a new entry rather than a parameter on xy_range_indices

The whole-column form stays the right call when nothing is pruned (no row-id
array to build or read), and it is used on the drill path as well. Adding an
optional rows would make every caller pay a branch and a null check for a case
half of them never hit.

Verification

Parity against the gathered form is the gate, not inspection. test_range_indices_rows.py:

  • five windows (narrow, whole-domain, empty, degenerate point, one quadrant)
    crossed with four row subsets (every row, strided, last-row-only, empty) and
    five sizes up to 100k
  • a 2M-row case that exercises the threaded path and its gather-down
  • NaN and both infinities on each axis — non-finite is never inside, matching
    range_indices
  • inclusive bounds on all four edges
  • unsorted row order preserved rather than sorted
  • invalid windows and an out-of-range row id, both as errors

test_selection_prunes_non_overlapping_zone_chunks now also asserts that exactly
one scan happens and that it reads the whole column — which is what proves the
gather is gone rather than merely moved.

Plus a figure-level check that both pruning branches agree with a plain NumPy
predicate over four windows.

118 Rust tests, 133 ABI smoke checks, 2445 Python tests pass. A 97-artifact
output fingerprint sweep is identical to main.

Perf

Interleaved A/B against main with the native core swapped per arm: neutral, no
region more than 5% worse under drift-cancelling round ordering.

Summary by CodeRabbit

  • New Features
    • Added rectangular range filtering for specified row IDs.
    • Preserves the input row order and supports inclusive boundaries.
    • Rejects invalid row IDs and window parameters with clear errors.
  • Bug Fixes
    • Improved polygon selection validation for out-of-range row IDs.
    • Ensured non-finite coordinates are excluded consistently.
  • Performance
    • Reduced temporary data creation during candidate selection.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Alek99, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e725a36-5b44-44b5-a512-26da2ac7d88b

📥 Commits

Reviewing files that changed from the base of the PR and between 00fe1e7 and c24f1b2.

📒 Files selected for processing (8)
  • python/xy/_native.py
  • python/xy/interaction.py
  • python/xy/kernels.py
  • src/kernels.rs
  • src/lib.rs
  • tests/test_range_indices_rows.py
  • tests/test_scatter.py
  • tests/test_select_polygon.py
📝 Walkthrough

Walkthrough

Adds a native row-restricted range-selection kernel, exposes it through Python bindings, updates select_range to use canonical candidate rows directly, hardens polygon row validation, and adds coverage for filtering semantics and pruning behavior.

Changes

Row-restricted range selection

Layer / File(s) Summary
Native row-filtering and ABI
src/kernels.rs, src/lib.rs, python/xy/_native.py
Adds candidate-row filtering with inclusive bounds and input-order preservation, validates invalid row IDs, exports xy_range_indices_rows, and increments the ABI version to 43.
Python binding and selection integration
python/xy/_native.py, python/xy/kernels.py, python/xy/interaction.py
Adds the native signature and wrapper, exports range_indices_rows, and uses it to filter canonical candidates without gathering coordinate arrays.
Selection and error validation
tests/test_range_indices_rows.py, tests/test_scatter.py, tests/test_select_polygon.py, src/kernels.rs
Tests bounds, ordering, non-finite values, invalid inputs, parallel consistency, integration behavior, pruning scans, and polygon row validation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • reflex-dev/xy#265: Both changes modify native and Python polygon_select plumbing and its error behavior.

Suggested reviewers: farhanaliraza, masenf

Sequence Diagram(s)

sequenceDiagram
  participant select_range
  participant PythonBinding
  participant xy_range_indices_rows
  participant RustKernel
  select_range->>PythonBinding: provide coordinates and candidate rows
  PythonBinding->>xy_range_indices_rows: call native row-filtering API
  xy_range_indices_rows->>RustKernel: validate and scan candidate rows
  RustKernel-->>xy_range_indices_rows: return matching canonical IDs
  xy_range_indices_rows-->>PythonBinding: return filtered IDs
  PythonBinding-->>select_range: provide selected rows
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: in-place candidate scanning, and it also notes the ABI bump.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/mem-audit-2-abi

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×2

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 102 untouched benchmarks
⏩ 2 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
test_select_box_zone_pruned_1m 3.4 ms 1.7 ms ×2

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing alek/mem-audit-2-abi (c24f1b2) with main (7c377ce)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Alek99
Alek99 force-pushed the alek/mem-audit-2-abi branch 3 times, most recently from 0c49eca to d965032 Compare July 26, 2026 21:56
@Alek99 Alek99 changed the title Scan selection candidates in place (ABI 41 -> 42) Scan selection candidates in place (ABI 42 -> 43) Jul 26, 2026
@Alek99
Alek99 force-pushed the alek/mem-audit-2-abi branch 2 times, most recently from 4cf2074 to 5c51d09 Compare July 26, 2026 23:27
Alek99 added 5 commits July 26, 2026 16:53
`select_range` prunes by zone map before it scans, and then threw the saving
away: the pruned branch gathered `x[candidates]` and `y[candidates]` into two
fresh f64 columns purely so it could call `range_indices`, which only knows how
to walk a whole column. Sixteen bytes per candidate, which produces the
backwards result that selecting *part* of a trace costs far more than selecting
all of it — on a 10M-row scatter, a half-domain box peaked at 134.6 MB while a
full-domain box peaked at 38.1 MB.

`xy_range_indices_rows` is the rectangular twin of the lasso's
`xy_polygon_select`: same `(x, y, len, rows, n_rows, ..., out)` shape, same
inclusive comparison chain as `range_scan_scalar`, same order-preserving
gather-down across worker segments. The pruned branch now reads through the row
ids and gets canonical ids straight back, so both the gathers and the
`candidates[hits]` re-index are gone. The half-domain case drops to 57.6 MB and
the full-domain case is untouched.

ABI_VERSION goes 42 -> 43 in `src/lib.rs` and `python/xy/_native.py` together.

Parity is the gate, not inspection: the new kernel is tested against
`range_indices` over the gathered form for five windows (including empty,
degenerate-point and whole-domain) crossed with five row subsets and five
sizes up to 100k, plus a 2M-row case that exercises the threaded path, NaN and
infinities on both axes, inclusive bounds, unsorted row order, invalid windows
and an out-of-range row id. `test_selection_prunes_non_overlapping_zone_chunks`
now also asserts that exactly one scan happens and that it reads the whole
column, which is what proves the gather is gone.

118 Rust tests, 133 ABI smoke checks and the full Python suite pass; a
97-artifact output fingerprint sweep is identical to main.
`range_indices_rows` and `polygon_select` index `x[row]` and left the
report to Rust's bounds check, which panics. `ffi_guard` turns a panic
into the entry point's sentinel only where panics unwind, and the
PyEmscripten wheel is built `-C panic=abort` precisely so they cannot:
there the same id aborted the Pyodide instance (exit 134) rather than
raising ValueError. `xy.kernels` is public API, so these row arrays are
caller data — the documented contract has to hold on every target, not
just the ones that unwind.

Both kernels now read through `get` and return `Option`, with the FFI
layer mapping `None` to the existing sentinel. This is free: the same two
bounds checks happen either way, so only the reporting changes. A
separate validating pass over `rows` was not free and is deliberately not
what this does — one serial sweep of 5M u32 cost more than the entire
parallel scan (1.0 ms -> 2.4 ms), where reading through `get` measures
1.01 ms against 1.02 ms, and `polygon_select` 36.3 ms against 36.4 ms.
claiming 43 for `xy_range_indices_rows`, and open PR #327 already claims
44. Both edits touch the same two lines with the same value, so such a
merge is clean and nothing complains — which is the problem. One number
would name two different ABIs, and `scripts/abi_smoke.py` compares the
built library against the wrapper's constant, so it cannot catch a
collision where both sides agree: a cdylib built from either tree would
load against the other's wrapper and fail at dlsym instead of at the
version check that exists to prevent exactly that.

45 is unclaimed across every open PR. It also fails loudly rather than
silently if #327 lands second, because its 43 -> 44 edit then conflicts
against a main at 45 instead of merging into agreement.
The out-of-range guarantee the previous commit made stops at the ctypes
boundary: the wrapper reached the kernel through
`ascontiguousarray(rows, dtype=np.uint32)`, an unchecked C cast. An id of
`2**32 + 3` arrives as row 3 — in range, indistinguishable from a real id,
so the bounds check passes and the call returns a row the caller never
asked for. `kernels.range_indices_rows(x, y, [2**32 + 3], ...)` answered
`[3]`; a negative id only errored by accident, because `-1` wraps to
4294967295 and that happened to exceed the column length.

Both row-restricted kernels now widen and range-check before casting.
The internal caller passes uint32 already (`_expand_zone_chunks`), which
takes the pass-through branch, so the hot path is untouched — 5M
candidates still 1.00 ms.
The range check widened through `astype(np.int64)`, which still let floats
through silently and, worse, decided them differently per architecture:
`[3.9]` selected row 3, and `[nan]` selected row 0 on arm64 because the NaN
cast saturates, where x86_64 traps it to INT64_MIN and the range check
would have caught it. A guard whose answer depends on the wheel is not a
guard. The cast also emitted a RuntimeWarning from inside the wrapper.

Integer dtypes only now, with an empty array exempted because
`np.asarray([])` is float64 and selecting no rows is not a dtype error.
@Alek99
Alek99 force-pushed the alek/mem-audit-2-abi branch from ae59224 to c24f1b2 Compare July 26, 2026 23:57
@Alek99
Alek99 merged commit 37d87c7 into main Jul 27, 2026
28 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