Skip to content

PERF: Trace batch simple segments - #2755

Open
headtr1ck wants to merge 2 commits into
SciTools:mainfrom
headtr1ck:trace-batch-simple-segments
Open

headtr1ck wants to merge 2 commits into
SciTools:mainfrom
headtr1ck:trace-batch-simple-segments

Conversation

@headtr1ck

Copy link
Copy Markdown

Rationale

Projecting large geometries with Projection.project_geometry is slow, which is a big part of #2102. For example, projecting the single 10m Natural Earth ocean polygon (444k vertices) into Mercator takes ~12 s, while transforming the same vertices takes 0.05 s. Profiling showed two causes, one per commit:

  1. Per-segment Python calls in trace.pyx. project_linear transforms all vertices at once, but then checks each segment separately. For every segment it:

    • tests whether the start point is in the domain (shapely.intersects_xy);
    • projects the mid-point with a single-point pyproj transform, to check the segment is straight enough;
    • creates a LineString to test whether the segment is covered by, or disjoint from, the domain.

    For the 10m ocean that's ~445k LineStrings, ~440k point tests and ~890k single-point transforms.

    This PR does the same checks for all segments at once before the loop. Segments that _project_segment would accept as a whole in the first step of its bisection are handled directly:

    • inside the domain: add the end point;
    • outside the domain: add nothing.

    Only the remaining segments are still bisected: ones that cross the boundary, contain points that can't be projected, or need resampling. The straightness calculation moves into a shared is_straight helper used by both paths, so the maths is unchanged.

  2. Quadratic time in Projection._attach_lines_to_boundary. Each time a boundary point or another line was added to a ring, a new LineString was created from all coordinates so far. For the 10m ocean in Robinson, every boundary vertex is added separately to a ring with ~100k coordinates, which took ~16 s. The coordinates of the pieces are now collected and the LineString is created once, when the ring is closed.

The output is unchanged. I compared the WKB of the projected geometries before and after this PR for 24,624 cases:

  • 729 geometries: Natural Earth 110m land, coastline, ocean, lakes and rivers; 50m land; and random polygons and lines, including ones near the poles and the antimeridian;
  • 27 projections;
  • PlateCarree and Geodetic sources.

All results are byte-identical, including the cases that raise errors. The images of the example from #2102 are pixel-identical as well.

Benchmarks

asv run --python=same, comparing main, #2752, this PR, and #2752 combined with this PR. The feature artist benchmarks come from #2752 and were copied in for the runs without it. Speed-ups are relative to main.

feature_artist.DrawNaturalEarthFeature.time_draw (Natural Earth land, LambertConformal)

extent resolution main #2752 this PR #2752 + this PR
regional 110m 31.9 ms 31.9 ms 8.67 ms (3.7×) 8.35 ms (3.8×)
regional 50m 256 ms 252 ms 32.0 ms (8.0×) 29.4 ms (8.7×)
regional 10m 11.4 s 1.76 s (6.5×) 3.18 s (3.6×) 175 ms (65×)
global 110m 156 ms 171 ms 69.5 ms (2.2×) 71.4 ms (2.2×)
global 50m 1.72 s 1.75 s 635 ms (2.7×) 625 ms (2.8×)
global 10m 12.6 s 11.9 s 3.58 s (3.5×) 3.44 s (3.7×)

feature_artist.DrawNaturalEarthFeaturesLazily.time_savefig: the example from #2102 (10m ocean, land and borders around Italy)

projection main #2752 this PR #2752 + this PR
PlateCarree 1.59 s 1.39 s 1.62 s 1.42 s
Mercator 23.4 s 15.1 s (1.5×) 5.47 s (4.3×) 3.55 s (6.6×)

No projection is needed for PlateCarree, so this PR doesn't change that case.

project_linear.Suite.time_project_linear (Natural Earth ocean)

projection resolution main #2752 this PR #2752 + this PR
PlateCarree 110m 1.17 ms 1.15 ms 887 µs (1.3×) 860 µs (1.4×)
PlateCarree 50m 821 ms 860 ms 522 ms (1.6×) 518 ms (1.6×)
NorthPolarStereo 110m 1.95 ms 2.02 ms 818 µs (2.4×) 828 µs (2.4×)
NorthPolarStereo 50m 1.88 s 1.93 s 545 ms (3.4×) 547 ms (3.4×)
Robinson 110m 982 µs 996 µs 740 µs (1.3×) 717 µs (1.4×)
Robinson 50m 1.39 s 1.37 s 821 ms (1.7×) 786 ms (1.8×)
InterruptedGoodeHomolosine 110m 2.02 ms 2.00 ms 831 µs (2.4×) 772 µs (2.6×)
InterruptedGoodeHomolosine 50m 2.01 s 2.02 s 699 ms (2.9×) 668 ms (3.0×)

#2752 doesn't touch project_geometry, so its column is just noise here.

The two PRs complement each other:

Together, the regional 10m map goes from 11.4 s to 0.18 s, and the #2102 example in Mercator from 23.4 s to 3.6 s.

Implications

  • Projecting lines and polygons is faster for all projections, and the results are identical. This helps features, add_geometries, contours and anything else that goes through project_geometry.
  • Nothing is cached or depends on the view, so interactive panning and zooming benefit in the same way as static images.
  • The existing geom_fully_inside fast path, which only exists for (warped) rectangular projections, now matters much less, since the per-segment domain checks are cheap for every projection.
  • The remaining time for the 10m ocean (~2 s) is mostly fixed overhead per ring, e.g. for the ~6,800 islands, which are projected one at a time. Batching rings would be a possible follow-up.
  • No new tests. The behaviour is unchanged, and the existing tests of project_geometry and the image tests cover both code paths.

🤖 Generated with Claude Code

headtr1ck and others added 2 commits September 30, 2026 11:14
When projecting a line, each segment was checked separately: whether
its start point is in the domain, whether the segment is straight
enough (projecting its mid-point), and whether it is covered by or
disjoint from the domain (creating a LineString). These Python-level
calls dominated the time for large geometries, e.g. about 445k
LineStrings for the 10m Natural Earth ocean.

Do the same checks for all segments at once before the loop. Segments
that would be accepted as a whole in the first step of the bisection
are handled directly, and only the others are still bisected. The
straightness calculation is shared by both paths, so the result is
unchanged.

For the example from SciTools#2102 with a Mercator projection, this reduces the
time from ~25 s to ~9 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_attach_lines_to_boundary created a new LineString from all coordinates
so far for every boundary point or line that was added to a ring. For
rings with many pieces, e.g. the 10m Natural Earth ocean in Robinson
where every boundary vertex is added separately, this took quadratic
time (~16 s). Collect the coordinates of the pieces instead and create
the LineString once the ring is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rcomer

rcomer commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Thanks @headtr1ck. Points 1 and 2 seem to be independent of each other. Did you benchmark them individually? I'd be curious to know if one or other of them is providing a significant part of the speedup.

Note that _attach_lines_to_boundary is getting a complete re-write at #2658.

I'm not qualified to review the cython changes, so this one will have to wait for someone else.

I would definitely be glad to see a speed up to contour plotting though, as I've noticed that is a bottleneck in some of my team's workflows.

@headtr1ck

headtr1ck commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

Thanks @headtr1ck. Points 1 and 2 seem to be independent of each other. Did you benchmark them individually? I'd be curious to know if one or other of them is providing a significant part of the speedup.

Yes, see the tables in the PR description. This PR speeds up projection altogether while the other one mainly benefits small cutouts.

Edit: sorry I misunderstood. Let me have a look.

Note that _attach_lines_to_boundary is getting a complete re-write at #2658.

If you decide to merge it before I am happy to rebase this PR.

This branch has not been deployed

No deployments
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.

2 participants