Skip to content

Cast lasso rays in the core instead of once per polygon edge#265

Merged
Alek99 merged 2 commits into
mainfrom
alek/native-polygon-select
Jul 24, 2026
Merged

Cast lasso rays in the core instead of once per polygon edge#265
Alek99 merged 2 commits into
mainfrom
alek/native-polygon-select

Conversation

@Alek99

@Alek99 Alek99 commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

select_polygon bounded the work correctly — zone-pruned bbox first, ray casting only over those candidates — and then spent all of it in NumPy, one vectorized pass per polygon edge. Each pass materialized full-length crossing and edge-intersection temporaries over every candidate, so a 64-vertex lasso over 160k candidates moved roughly half a gigabyte through memory to answer a question that fits in registers. Worse, cost grew linearly with vertex count: a 2048-vertex freehand lasso — the documented API ceiling — took ~370 ms. Every other selection primitive was already native; this one was not.

kernels.polygon_select walks edges inside the point loop. Crossing parity is order-independent, so the answer is unchanged.

On top of that it buckets edges into y slabs. An edge can only flip parity for a point inside its own y-extent, so the index hands back a superset of the edges that can cross a given row and the rest are provably no-ops — restricting the candidate set is not an approximation. That is what flattens the vertex scaling.

Numbers

1M uniform points, 160k bbox candidates, min-of-3:

vertices numpy native speedup
16 3.70 ms 2.56 ms 1.4x
64 12.40 ms 3.20 ms 3.9x
256 48.30 ms 3.03 ms 15.9x
1024 188.18 ms 4.47 ms 42.1x
2048 377.57 ms 5.39 ms 70.0x

The full gesture unit test_select_lasso_message_1m measures — dispatch, bbox prune, cast, wire mask reply — goes 13.59 ms -> 3.21 ms (min-of-5).

There is a second reason to want this. test_select_lasso_message_1m has been the suite's largest source of false alarms: it is bimodal at ~90 ms and ~123-141 ms and flips on changes that cannot reach it — docs-only #251 read "+39%", CI-only #236 read "-25%", and #264 (which touches only marks.py and _figure.py) just read "-27%", each with the bot's "different runtime environments" warning attached. A memory-bound NumPy loop is exactly the workload whose modeled cost swings when the runner changes; collapsing it to a register-resident scan should shrink that sensitivity considerably.

Correctness

The arithmetic is preserved term for term. x_j - x_i and y_j - y_i are hoisted per edge — exact subtractions — but the division is not strength-reduced to a reciprocal multiply, which would not be. The guarded division covers exactly the lanes the vectorized form computed unconditionally and then masked its inf/NaN away, since endpoints straddling the ray cannot have equal y. Bucketing is declined for a non-finite polygon, where slab bounds stop meaning anything, and below 16 vertices, where it would not pay for itself.

Verified identical to the previous predicate on:

  • convex, concave (star), and self-intersecting (bowtie — even-odd parity is load-bearing) polygons
  • axis-aligned boxes, whose horizontal edges are the masked-division lanes
  • collinear, zero-height, degenerate and repeated-vertex polygons
  • points landing exactly on vertices and edges
  • NaN and +-inf coordinates
  • sparse candidate subsets (the real call shape), with canonical ascending order preserved
  • both sides of the 16-vertex index gate, and up to the 2048-vertex ceiling

Both the Rust unit test and tests/test_select_polygon.py carry the per-edge formulation as an executable reference rather than describing it in a comment.

  • cargo test — 117 passed; cargo clippy clean; no new cargo fmt diffs
  • full Python suite — 2240 passed, 4 skipped
  • scripts/abi_smoke.py — 133 checks passed
  • ABI 40 -> 41, bumped in src/lib.rs and python/xy/_native.py together
  • placement audit in spec/design/rust-engine.md updated

Summary by CodeRabbit

  • New Features

    • Added native-accelerated “lasso” polygon selection for points, using candidate row subsets and returning matching point IDs in order.
  • Bug Fixes

    • Improved selection consistency across concave, self-intersecting, degenerate, and non-finite geometries.
  • Tests

    • Added extensive pytest coverage for native polygon selection accuracy, subset behavior, ordering, and input validation (including invalid polygon/row/array shapes and bounds).

`select_polygon` bounded the work correctly — zone-pruned bbox first, ray
casting only over those candidates — and then spent all of it in NumPy, one
vectorized pass per polygon EDGE. Each pass materialized full-length
crossing and edge-intersection temporaries over every candidate, so a
64-vertex lasso over 160k candidates moved roughly half a gigabyte through
memory to answer a question that fits in registers, and cost grew linearly
with vertex count: a 2048-vertex freehand lasso (the API ceiling) took
~370 ms. Every other selection primitive was already native; this one was
not.

`kernels.polygon_select` walks edges inside the point loop. Crossing parity
is order-independent, so the answer is unchanged. On top of that it buckets
edges into y slabs, because an edge can only flip parity for a point inside
its own y-extent — the index hands back a superset of the edges that can
cross a given row and the rest are provably no-ops, so restricting the
candidate set is not an approximation. That is what flattens the vertex
scaling.

  vertices    numpy    native   speedup
        16   3.70ms    2.56ms      1.4x
        64  12.40ms    3.20ms      3.9x
       256  48.30ms    3.03ms     15.9x
      1024 188.18ms    4.47ms     42.1x
      2048 377.57ms    5.39ms     70.0x

The full gesture unit the benchmark measures (dispatch, bbox prune, cast,
wire mask reply) goes 13.59ms -> 3.21ms, min-of-5.

The arithmetic is preserved term for term: `x_j - x_i` and `y_j - y_i` are
hoisted per edge (exact subtractions) but the division is NOT strength-
reduced to a reciprocal multiply, which would not be. The guarded division
covers exactly the lanes the vectorized form computed unconditionally and
then masked its inf/NaN away, since endpoints straddling the ray cannot
have equal y. Bucketing is declined for a non-finite polygon, where slab
bounds stop meaning anything.

Verified identical to the previous predicate on convex, concave, self-
intersecting (bowtie — even-odd parity is load-bearing), axis-aligned
(horizontal edges are the masked-division lanes), collinear, degenerate,
repeated-vertex and 2048-vertex polygons; on points landing exactly on
vertices and edges; on NaN/+-inf coordinates; and on sparse candidate
subsets. Both the Rust and Python tests carry the per-edge formulation as
an executable reference rather than describing it.

ABI 40 -> 41.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 029189ea-5bc8-4208-9136-621c756052d6

📥 Commits

Reviewing files that changed from the base of the PR and between 494cf01 and 6401310.

📒 Files selected for processing (4)
  • spec/design/rust-engine.md
  • src/kernels.rs
  • src/lib.rs
  • tests/test_select_polygon.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • spec/design/rust-engine.md
  • src/kernels.rs
  • src/lib.rs
  • tests/test_select_polygon.py

📝 Walkthrough

Walkthrough

Polygon lasso selection now uses a Rust native kernel exposed through ABI v41, with ctypes marshaling and Python integration. The kernel supports indexed edge traversal, non-finite inputs, candidate row subsets, and expanded predicate and end-to-end tests.

Changes

Polygon selection

Layer / File(s) Summary
Rust polygon kernel and predicate coverage
src/kernels.rs, spec/design/rust-engine.md
Rust performs even–odd selection with optional y-slab edge indexing, while tests compare indexed and unindexed paths against a per-edge reference.
ABI entrypoint and Python native wrapper
src/lib.rs, python/xy/_native.py
ABI v41 exposes validated xy_polygon_select; the Python wrapper configures ctypes, marshals arrays, handles native errors, and trims native output.
Kernel export and figure integration
python/xy/kernels.py, python/xy/interaction.py, tests/test_select_polygon.py
The kernel is publicly re-exported, and figure polygon selection delegates candidate rows to the native implementation with validation and end-to-end coverage.

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

Sequence Diagram(s)

sequenceDiagram
  participant Figure
  participant select_polygon
  participant kernels.polygon_select
  participant xy_polygon_select
  participant RustPolygonKernel
  Figure->>select_polygon: select polygon candidates
  select_polygon->>kernels.polygon_select: pass points, rows, and polygon
  kernels.polygon_select->>xy_polygon_select: marshal native call
  xy_polygon_select->>RustPolygonKernel: validate and select rows
  RustPolygonKernel-->>xy_polygon_select: selected row IDs
  xy_polygon_select-->>kernels.polygon_select: output count and IDs
  kernels.polygon_select-->>select_polygon: selected rows
  select_polygon-->>Figure: update selection
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving polygon lasso ray-casting from per-edge Python work into the native core.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/native-polygon-select

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

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×3.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
⏩ 1 skipped benchmark1

Performance Changes

Benchmark BASE HEAD Efficiency
test_select_lasso_message_1m 89.3 ms 27.6 ms ×3.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/native-polygon-select (6401310) with main (9b08085)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
spec/design/rust-engine.md (1)

28-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the lasso bucketing thresholds to spec/design/rust-engine.md:28. Document the >=16-vertex gate, the 256-slab cap, and the fallback for non-finite or zero-height polygons; that row also still says Rust (ABI v41), so update it to match the current ABI version (v36).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/design/rust-engine.md` at line 28, Update the Rust row in rust-engine.md
to document lasso edge bucketing thresholds: enable it for polygons with at
least 16 vertices, cap bucketing at 256 slabs, and fall back for non-finite or
zero-height polygons. Change the row’s ABI label from Rust (ABI v41) to Rust
(ABI v36).

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/kernels.rs`:
- Around line 4885-4898: Add a local maximum vertex-count guard in the polygon
indexing path around n_edges before allocating CSR-related buffers, returning
unindexed() when the ceiling is exceeded. Ensure the limit prevents u32 count
overflow and excessive transient allocation while preserving normal indexing
below the ceiling and the existing unindexed scan behavior above it.

In `@src/lib.rs`:
- Around line 2831-2839: Update the polygon-processing function around the
existing n_poly pointer validation to return usize::MAX whenever n_poly is less
than 3, before any std::slice::from_raw_parts calls. Preserve the existing
null-pointer validation for non-empty polygons and ensure poly_x/poly_y are
never sliced on the empty or insufficient-polygon path.

---

Nitpick comments:
In `@spec/design/rust-engine.md`:
- Line 28: Update the Rust row in rust-engine.md to document lasso edge
bucketing thresholds: enable it for polygons with at least 16 vertices, cap
bucketing at 256 slabs, and fall back for non-finite or zero-height polygons.
Change the row’s ABI label from Rust (ABI v41) to Rust (ABI v36).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: baf89600-51c5-4341-9407-7e465edd139d

📥 Commits

Reviewing files that changed from the base of the PR and between 9b08085 and 494cf01.

📒 Files selected for processing (7)
  • python/xy/_native.py
  • python/xy/interaction.py
  • python/xy/kernels.py
  • spec/design/rust-engine.md
  • src/kernels.rs
  • src/lib.rs
  • tests/test_select_polygon.py

Comment thread src/kernels.rs Outdated
Comment thread src/lib.rs Outdated
Two defects review caught in the polygon cast.

`xy_polygon_select` built its polygon slices before checking the vertex
count, so a caller passing null for an empty polygon reached
`slice::from_raw_parts(null, 0)` — which requires a non-null pointer even at
length zero. Answer the "fewer than three vertices encloses nothing" case
first, before any slice exists.

The slab index sized itself purely from the vertex count. `select_polygon`
caps callers at 2048, but the kernel and its C export are public with no
such ceiling, so a direct call with a very large polygon transiently
allocated hundreds of MB, and past ~16.7M vertices the u32 span counters
would have overflowed into a corrupt CSR. Cap the CSR at 2^20 entries and
shed slabs to stay inside it, rather than refusing to index outright: the
fallback scan is O(edges) for every point, which is exactly what hurts most
at the vertex counts this guard is about. Below 8 slabs the index stops
earning its build and declines.

Neither path changes an answer: the budget only coarsens bucketing, and
bucketing only ever restricts which edges are tested to a superset of those
that can cross. Equivalence to the per-edge reference still holds across
every shape, the 2048-vertex ceiling is now covered on the Python side, and
a new Rust test pins the CSR invariant at 16 / 2048 / 65536 / 200000
vertices with every edge spanning the full height.
@Alek99

Alek99 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Both review findings were real and are fixed in 6401310.

Zero-length from_raw_parts (src/lib.rs). Correct — the polygon slices were built before the vertex count was checked, so a caller passing null for an empty polygon reached slice::from_raw_parts(null, 0), which requires a non-null pointer even at length zero. The "fewer than three vertices encloses nothing" case is now answered before any slice exists.

Unbounded CSR (src/kernels.rs). Also correct, and the reasoning about select_polygon's 2048-vertex cap not covering the kernel's own contract is right.

I took a different fix than the suggested 16..=1<<16 ceiling, though. Refusing to index above a vertex ceiling sends exactly the largest polygons — the ones that hurt most — to the fallback scan, which is O(edges) for every point; at 100k vertices over 160k candidates that is worse than what this PR replaced. Instead the CSR is capped at 2²⁰ entries and the slab count is shed to stay inside it, so a huge polygon keeps an index with coarser buckets. Below 8 slabs the index no longer earns its build and declines. That bounds the allocation at 4 MB and keeps the u32 cursors well clear of overflow, while preserving the property the PR is about.

Neither change can move an answer: the budget only coarsens bucketing, and bucketing only ever restricts the tested edges to a superset of those that can cross the row.

Added coverage: a Rust test pinning the CSR invariant at 16 / 2048 / 65536 / 200000 vertices with every edge spanning the full height (the worst case for the index), and ring_2048 — the API ceiling — added to the Python equivalence sweep.

On the spec nitpick: thresholds are now recorded in spec/design/rust-engine.md (the < 16 gate, the 256-slab cap, the record budget, and the non-finite fallback).

Re-verified: 118 Rust tests, 2241 Python tests, clippy clean, no new cargo fmt diffs, ABI smoke 133 checks. The 2048-vertex speedup is unchanged at 74x.

@Alek99
Alek99 merged commit 119a1ce into main Jul 24, 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