Benchmarks
July 2, 2026 · View on GitHub
geo-utils-cpp is a small, dependency-free lat/lng geometry library that
stays close to hand-written spherical math while avoiding a full geometry
framework dependency. This page shows the benchmark results, methodology,
and trade-offs against S2 Geometry, Boost.Geometry, and GeographicLib.
For build and run instructions see benchmarks/README.md.
TL;DR
- Speed. Matches Boost.Geometry's spherical strategy and hand-written
haversine on
distance/heading; wins onareaandpoint_at_distance; losescontains,path_length, and snap-to-route to S2, which pays a hiddenlat/lng → S2Pointconversion in real workloads. ~30× faster than GeographicLib's WGS84 geodesic — but on a sphere, less accurate. - Deployment footprint. Header-only, zero deps — nothing to add to
your build's dependency tree, and no
.so/.dylibto ship alongside the binary. - When each library wins. S2 wins on
containsand on several algorithm-only speed tests, especially if your data already lives asS2Pointend-to-end. geo-utils-cpp is best-in-class onarea, and on lat/lng-input workloads where adding a geometry framework to your build isn't an option. Full per-library guidance is in Where each library is the right tool.
Methodology
| Item | Value |
|---|---|
| Compiler | Apple clang 17 (-std=c++17 -O2 -DNDEBUG) |
| Build type | Release |
| Benchmark harness | Google Benchmark 1.8.4 |
| Host | Apple M1, 8 cores, 8 GB RAM, macOS 15.7 |
| Random data | Mersenne-twister seeded to a fixed value, lat ∈ [-80, 80], lng ∈ [-180, 180] |
| Library versions | s2geometry 0.14.0 · boost 1.90.0 · geographiclib 2.7 |
Each library is fed identical inputs (see
benchmarks/common/random_data.hpp).
Each library's native point/geometry types are pre-built outside the timed
loop. The timed work is the per-call computation (bg::distance,
S2Loop::Contains, pa.Compute(), etc.) — this isolates algorithmic cost
from lat/lng → native-type plumbing. geo-utils-cpp's API takes lat/lng
directly, so it has nothing to pre-build; this is a real API-shape advantage
but is not what the speed numbers below measure.
Apples-to-apples notes
- S2 vs us: both on a sphere. Fair on accuracy.
S2Loopstores a bounding rectangle with the loop and uses it as an early-exit predicate insideContains; that, plus a tightly inlined edge-crossing routine, is what makes itscontainsscale differently from our linear ray-cast. Note: this is not spatial indexing — that lives inS2ShapeIndex, which we do not construct here. - Boost.Geometry vs us: uses
cs::spherical_equatorial<degree>. Fair. - GeographicLib vs us: uses Karney's iterative WGS84 geodesic. Slower and more accurate. Treat as a trade-off data point, not a "we are faster" claim.
- GeographicLib
PolygonAreais timed differently forarea/path_length. It's an incremental accumulator:AddPointitself does the per-vertex geodesicInverse()call, andCompute()only finalizes the closing edge. We therefore measure the fullPolygonArea + N×AddPoint + Compute()pattern inside the timed loop — that's what computing the area of an N-vertex polygon actually costs in GeographicLib. The other libraries pre-build native types outside the loop; for GeographicLib there is no separable "pre-build" step to lift. - GeographicLib has no native point-in-polygon. Real capability gap.
- S2 has no public initial-bearing API. Same.
Speed results
Throughput in million items per second (higher is better). All numbers from
the host described above. Run ./build-bench/benchmarks/bench_* locally
to reproduce. Bold number = column winner, or co-winners within ~5%
(noise-level tie); bold library name = this library (geo-utils-cpp).
distance_between
| Library | N=1 000 | N=100 000 |
|---|---|---|
| geo-utils-cpp | 40.5 | 28.2 |
| naive haversine | 38.3 | 26.0 |
| S2 Geometry | 82.9 | 29.1 |
| Boost.Geometry | 39.8 | 28.8 |
| GeographicLib | 1.25 | 1.24 |
We tie naive haversine and Boost.Geometry's spherical strategy within
noise — zero overhead from being a library. The "naive" baseline is a
deliberately textbook haversine (recomputes * π / 180 per call, no
trig caching); geo-utils-cpp's actual implementation is hand-optimized
(cached deg2rad factors, combined arc_hav reductions), so "ties
naive" really means "the optimized library version is no slower than a
hand-rolled one-liner — overhead is zero". S2 is 2× faster at small N
because once the input is S2Point the per-pair distance reduces to a dot
product / acos, cheaper than haversine; the gap closes at N=100 000 where
all three become memory-bandwidth bound. Note: in real-world workloads
where the input is lat/lng, S2 also pays a per-call lat/lng→S2Point
conversion that isn't counted here. GeographicLib is ~25× slower (and
substantially more accurate on long-distance pairs).
heading
| Library | N=1 000 | N=100 000 |
|---|---|---|
| geo-utils-cpp | 24.9 | 15.5 |
| Boost.Geometry | 22.5 | 14.7 |
| GeographicLib | 1.16 | 1.16 |
| S2 Geometry | — | — |
S2 has no public initial-bearing API.
contains (point-in-polygon)
Million queries per second (1 000 query points per iteration).
Apples-to-apples caveat for this op. The four libraries do not run the same algorithm here.
geo-utils-cppuses an O(N) ray-cast over rhumb-line edges (itsgeodesic=falsedefault — cheaper but less accurate near the poles). Boost.Geometry'sbg::withinwith the spherical CS auto-selectsstrategy::within::spherical_winding, which traces great-circle edges and is materially more expensive per edge. S2 wins partly through algorithm (3D edge-crossing) and partly through structure (a bounding-rectangle prefilter on the loop). The Boost gap below therefore reflects algorithm choice as much as raw speed; see the commentary after the table.
| Library | poly N=10 | poly N=100 | poly N=1 000 |
|---|---|---|---|
| geo-utils-cpp | 16.2 | 2.87 | 0.329 |
| S2 Geometry | 26.7 | 18.0 | 21.2 |
| Boost.Geometry | 1.91 | 0.234 | 0.024 |
| GeographicLib | — | — | — |
GeographicLib has no native point-in-polygon predicate.
Practical takeaway: if contains is the hot path and your data already
lives as S2Point end-to-end, S2 is the right tool — throughput is
roughly constant in N because of the prefilter. For lat/lng-input
workloads we beat Boost.Geometry by ~10× while remaining header-only.
area (M polygons/s × vertex count)
| Library | N=10 | N=100 | N=1 000 |
|---|---|---|---|
| geo-utils-cpp | 69.9 | 67.2 | 67.7 |
| S2 Geometry | 16.3 | 14.0 | 13.9 |
| Boost.Geometry | 45.0 | 36.2 | 36.6 |
| GeographicLib | 1.75 | 2.04 | 2.07 |
N = vertices per polygon.
We win clearly on area — our spherical-triangle accumulation is a tight
loop with no allocation; S2's S2Loop::GetArea does more work per
vertex, and Boost.Geometry's strategy machinery costs ~1.8×.
path_length (M points/s)
| Library | N=10 | N=100 | N=1 000 |
|---|---|---|---|
| geo-utils-cpp | 54.0 | 46.2 | 41.7 |
| S2 Geometry | 105.1 | 96.7 | 91.6 |
| Boost.Geometry | 48.7 | 43.5 | 40.2 |
| GeographicLib | 1.53 | 1.27 | 1.23 |
S2 wins on path_length algorithmically (~2×) — once the input is
S2Point, segment length is a fast cartesian computation. We tie
Boost.Geometry within noise. GeographicLib pays the ellipsoidal cost.
Note: a lat/lng-input workload would push the S2 column down by the
per-call conversion cost, which is not counted here.
closest_point_on_path (snap to route, M queries/s)
1 000 query points per iteration; route of N vertices. The comparable is
S2Polyline::Project — the same algorithmic class (a linear scan over the
segments, no spatial index; the indexed tool would be S2ClosestEdgeQuery,
a different trade-off).
| Library | N=10 | N=100 | N=1 000 |
|---|---|---|---|
| geo-utils-cpp | 1.53 | 0.189 | 0.021 |
S2 (S2Polyline::Project) | 4.41 | 0.708 | 0.076 |
Both sides walk every segment; S2's polyline is pre-built as unit vectors
while our lat/lng-native API converts each vertex per call — that
conversion is essentially the whole ~3× gap, the same asymmetry noted for
path_length above. For a lat/lng-input workload the columns converge.
point_at_distance (M queries/s)
100 distances spread along a route of N vertices, per iteration. The
comparable is S2Polyline::Interpolate.
| Library | N=10 | N=100 | N=1 000 |
|---|---|---|---|
| geo-utils-cpp | 4.21 | 0.79 | 0.096 |
S2 (S2Polyline::Interpolate) | 3.88 | 0.50 | 0.051 |
We win ~1.6–1.9× at larger N despite the per-vertex lat/lng conversion:
point_at_distance walks segments until the target distance and stops,
while S2Polyline::Interpolate normalizes by the full polyline length on
every call.
simplify: planar vs geodesic metric (M input vertices/s)
geo-utils-cpp only — the cost of the geodesic = true mode added for
antimeridian/high-latitude correctness, on a worst-case random input
(tolerance 1 km, nearly every vertex kept).
| Metric | N=100 | N=1 000 |
|---|---|---|
| planar (default) | 3.00 | 1.74 |
geodesic = true | 2.31 | 0.68 |
The geodesic metric costs ~1.3× at N=100 and ~2.6× at N=1 000 — the 3D-projection distance is doing real extra work per vertex. Reach for it when the polyline can cross the antimeridian or sits at high latitudes; the default stays bit-compatible with upstream PolyUtil.
Deployment footprint
A geometry library shows up in two places: at build time (what your
CMake has to find, what your container image has to install) and at
runtime (what has to sit alongside the binary so it can load the
library at startup). geo-utils-cpp is header-only with no deps, so
both are zero beyond the headers themselves.
The table below covers the runtime side — stripped binary size of a minimal "distance + point-in-polygon" consumer, dynamically linked against each library. Smaller is better.
| Library | Stripped binary | Notes |
|---|---|---|
| geo-utils-cpp | 33 KB | header-only — nothing else to ship |
| naive haversine | 33 KB | hand-written, no library |
| S2 Geometry | 33.4 KB | dynamic linking; libs2.dylib required at runtime |
| Boost.Geometry | 50.8 KB | header-only — nothing else to ship |
| GeographicLib | 33 KB | dynamic linking; libGeographicLib.dylib required; distance only — no PIP |
Bold marks the entries that ship nothing beyond the binary, not similarity of the size column itself.
These numbers are for dynamic linking: the binary itself stays small
because the library code lives in the shared object that has to be
present at runtime alongside the binary. Static linking shifts the cost
the other way — the binary grows, but only by the symbols the linker
actually keeps (with -ffunction-sections -Wl,--gc-sections or LTO,
unused code is pruned). For header-only libraries (geo-utils-cpp,
Boost.Geometry) there's nothing to ship beyond the binary in either mode.
The benchmarks/size/measure.sh script supports STATIC=1 if you want
to repeat the comparison for statically linked binaries on your own host
— numbers will depend on which symbols your code actually pulls in.
Where each library is the right tool
- geo-utils-cpp — lat/lng-native API, no-deps constraint,
areais hot, sphere accuracy is acceptable. Best when adding a geometry framework to your build (S2 + abseil, Boost.Geometry, GeographicLib) isn't an option, or when you'd otherwise be paying a lat/lng→native-type conversion on every call. - S2 Geometry —
contains/distance/path_lengthare the hot path, and you're willing to keep data asS2Pointend-to-end (otherwise the lat/lng→S2Point conversion eats the algorithmic win). Spatial indexing (S2ShapeIndex) available for bigger workloads. - Boost.Geometry — already-Boost project, want one library for many
geometry types and CSes. Ties us on most operations; loses on
areaand oncontains. - GeographicLib — sub-meter geodesic accuracy on the WGS84 ellipsoid. Slower by 1–2 orders of magnitude. No PIP.
Reproducing
Every benchmark is registered with Repetitions(5)->ReportAggregatesOnly(true),
so output shows _mean / _median / _stddev / _cv per data point —
that's what the numbers in the tables above are (median rows). Use the
standard Google Benchmark CLI flags (--benchmark_repetitions=N,
--benchmark_min_time=..., etc.) to override locally.
See benchmarks/README.md for build
prerequisites and target names.
# Speed
cmake -S . -B build-bench \
-DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-bench --target bench_all -j
for b in build-bench/benchmarks/bench_*; do "$b"; done
# Deployment footprint
./benchmarks/size/measure.sh
If a competitor is missing it will be skipped with a STATUS message during
CMake configure (or a "not installed" line from measure.sh). Install only
the competitors you care about.