Profiling GEOS Predicate Hot Paths
Profiling a spatial suite is different from profiling ordinary Python in one respect that changes everything: most of the time is spent inside a C extension, where a Python profiler sees a single opaque call. This guide sits beneath performance benchmarking spatial suites and covers reading a profile whose hot path is a GEOS call: which profiler to use, what the numbers mean when the work is invisible, and the two changes — prepared geometry and vertex reduction — that account for most of the available improvement.
The finding that recurs is worth stating first. Predicate cost is dominated by vertex count, not by feature count, and the two are uncorrelated in real data. A layer of a thousand highly-detailed polygons is far more expensive than one of ten thousand simple ones, and no amount of index tuning changes that.
Root cause: the profiler cannot see inside the predicate
A deterministic Python profiler records every call and return, which gives exact call counts and attributes time precisely — to the boundary of the C extension. Everything GEOS does inside intersects appears as time spent in one function, so the profile says which predicate is expensive and nothing about why.
That is less limiting than it sounds, because the actionable question is usually which call site dominates rather than which GEOS routine does. But it changes the reading: a profile showing 80 per cent of time in a single predicate call is not a dead end, it is an instruction to look at the inputs to that call.
Profiler reference
| Tool | Sees | Overhead | Right for |
|---|---|---|---|
| Deterministic call profiler | Exact call counts, Python frames | High — distorts short calls | Finding the dominant call site |
| Sampling profiler | Approximate distribution, native frames | Low | Confirming a hot path under realistic load |
| Native profiler | Inside the C extension | Moderate, needs symbols | Only when the engine itself is suspect |
| Manual counters | Exactly what you instrument | Negligible | A number you want in every run |
For spatial work the first two cover almost everything. The deterministic profiler answers “how many times is this predicate called”, which is usually the actionable question; the sampling profiler answers “where is the time actually going” without distorting the measurement, which matters when the calls are short and numerous.
Step-by-step implementation
Step 1 — Count the calls before timing them
The call count is the number that leads somewhere, and it is cheap enough to record on every run rather than only when profiling.
import cProfile, pstats, io
def profile_call_counts(func, *args, top=10):
pr = cProfile.Profile()
pr.enable(); func(*args); pr.disable()
s = io.StringIO()
pstats.Stats(pr, stream=s).sort_stats("tottime").print_stats(top)
return s.getvalue()
A predicate called forty thousand times when the dataset has a thousand features means the filter stage is not filtering — the same finding the candidate-count benchmark produces, arrived at from the other direction.
Step 2 — Use prepared geometry for repeated comparisons
When one geometry is compared against many, GEOS can build an internal index of it once and reuse it. This is the single largest available improvement in most spatial suites, and it is one line.
import shapely
def count_intersections(one, many):
shapely.prepare(one) # builds the internal index once
return sum(1 for g in many if shapely.intersects(one, g))
Without preparation the geometry is re-analysed on every comparison. With it, the analysis happens once and each comparison is a lookup — a difference that grows with the vertex count of the prepared geometry, which is exactly where the cost lives.
Step 3 — Measure the vertex count, not the feature count
Feature count is the number everybody records and vertex count is the one that predicts cost.
import shapely
import numpy as np
def vertex_profile(gdf) -> dict:
counts = shapely.get_num_coordinates(gdf.geometry.values)
return {
"features": int(len(counts)),
"vertices_total": int(counts.sum()),
"vertices_p95": int(np.percentile(counts, 95)),
"vertices_max": int(counts.max()),
}
The 95th percentile is the useful one. A layer whose median feature has 40 vertices and whose 95th percentile has 12,000 will be dominated by a handful of features, and simplifying only those changes the profile more than any other single action.
Step 4 — Confirm with a sampling profiler under load
A deterministic profiler distorts short calls by adding per-call overhead, which for a predicate called tens of thousands of times can invert the ranking. Confirm the finding without that distortion.
# Attach a sampling profiler to the real run rather than a profiled one.
py-spy record -o profile.svg -- pytest -q tests/bench/test_join.py
Verify the fix
Compare the profile before and after preparing the geometry:
pytest -q tests/bench/test_join.py --benchmark-only --benchmark-columns=median
Preparation should reduce the median substantially where one geometry is compared against many, and change nothing where each comparison involves a different pair — which is itself a useful confirmation that the call pattern is what you assumed.
The two changes that account for most of the improvement
Profiling a spatial suite tends to produce the same two findings, and knowing them in advance shortens the exercise considerably.
Prepared geometry, wherever one shape meets many. Any containment test against a boundary, any clip against a study area, any check of many features against one reference geometry. The improvement scales with the vertex count of the prepared shape, so it is largest exactly where the cost is worst.
Vertex reduction on the heavy tail. Not simplification of the whole layer — that changes the data — but a decision about whether the pipeline needs full detail at this stage. A validity check does; a bounding-box prefilter does not. Running cheap stages against a simplified copy and expensive ones against the original is often a large win with no loss of correctness, provided the simplified copy is used only where its use is sound.
A third finding appears less often and is worth naming because it is invisible otherwise: repeated construction of a transformer, an index, or a prepared geometry inside a loop. The profile shows many short calls whose total dominates, and the fix is to hoist the construction. This is the same class as the repeated-fixture finding, one level down.
When to stop profiling
Profiling has diminishing returns and a recognisable stopping point. Once the two standard remedies have been applied — preparation where one shape meets many, and construction hoisted out of loops — the remaining profile is usually flat: no single call site dominates, and further work means algorithmic change rather than tuning.
That flatness is the signal to stop. A flat profile means the cost is distributed across genuinely necessary work, and the next available improvement is a different approach rather than a faster version of this one: pushing the operation into the database, reducing the data the stage sees, or accepting the cost and adjusting the budget.
Recognising it early matters because profiling is absorbing. It is always possible to find another five per cent, and the effort is rarely worth it once the profile has flattened — particularly against the alternative of moving an expensive check to the scheduled tier, which is a five-minute change with a larger effect than any micro-optimisation available.
Failure modes and edge cases
- Profiling with a deterministic profiler and trusting the ranking. Per-call overhead can invert the order when calls are short and numerous. Confirm with a sampling profiler before acting.
- Preparing a geometry used once. Preparation costs an index build; if the shape is compared once, that is pure overhead. Prepare only for repeated use.
- Simplifying the data rather than a working copy. A simplified geometry that reaches the output is a correctness change wearing a performance justification. Simplify a copy, and use it only where the loss is sound.
- Profiling a fixture-dominated run. If the fixture build is most of the time, the predicate profile is a small slice of a small slice. Profile the operation, not the test.
- Optimising from a profile taken at fixture scale. The ranking at a thousand features can differ from the ranking at a million, particularly where an index is involved. Profile at a size that resembles production.
- Ignoring memory. A predicate pass that fits in cache and one that does not differ by more than any algorithmic change available, and the profile shows only the symptom.
Conclusion
A spatial profile has a horizon at the C extension boundary, and the information above that line is enough: call counts identify the dominant site, and the inputs to that site identify the remedy. Recording vertex counts rather than feature counts, preparing geometry wherever one shape meets many, hoisting construction out of loops, and confirming with a sampling profiler covers most of the improvement available — which is what makes the measurements in performance benchmarking spatial suites actionable rather than merely descriptive.
Related
- Performance Benchmarking Spatial Suites — the parent layer and the trend that sends you here.
- Benchmarking Spatial Joins with pytest-benchmark — the candidate count that reaches the same finding from the other side.
- Setting Runtime Budgets for Spatial Test Suites — where the recovered time is spent.
- Shapely vs PostGIS for In-Pipeline Topology Checks — when the answer is to move the work rather than to speed it up.