-
Notifications
You must be signed in to change notification settings - Fork 0
Benchmark
Performance and correctness benchmarks for DiGi.Geometry.
All benchmarks live as [Fact] tests in the DiGi.Geometry.xUnit test project
(DiGi.Test/DiGi.Geometry.xUnit/Facts/), so they can be re-run and re-verified at any time rather
than treated as a one-off snapshot.
| Component | Specification |
|---|---|
| CPU | AMD Ryzen 9 9950X, 16 cores / 32 threads (Environment.ProcessorCount = 32) |
| RAM | 61.4 GB |
| OS | Windows 11 Pro (10.0.26200) |
| .NET SDK | 10.0.301 |
| Build config | Release (unless noted) |
Numbers are machine-specific — re-run the benchmarks on your own hardware before drawing conclusions for a different environment.
File: Facts/OptimizationPerformance.cs
Methods compared:
-
DiGi.Geometry.Planar.Query.Average(IEnumerable<Point2D>)— the vertex mean center,C = (1/n) · Σ Pᵢ; a single pass accumulatingx/y(≈ 2 additions per vertex). -
DiGi.Geometry.Planar.Query.Centroid(IEnumerable<Point2D>)— the area centroid (polygon centre of mass),C = 1/(6A) · Σ (Pᵢ + Pᵢ₊₁)(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ); the shoelace-weighted sum (≈ 4 multiply-adds per vertex).
Both are O(n) single-pass queries. The benchmark sweeps the point count over
{100, 1 000, 10 000, 100 000, 1 000 000} — a regular polygon inscribed in a circle of radius 10
centred on the origin — and reports the per-call time at each scale. A warm-up call precedes the
measured loop (JIT), and the number of repeats is scaled inversely with the point count
(repeats = Max(1, 2 000 000 / count)) so the total measured work stays comparable across scales.
Each scale also asserts both queries resolve to the origin (correctness), so the timings compare
equivalent work.
Context: DiGi.Geometry.Planar.Query.InternalPoint uses the area centroid as its primary
internal-point candidate and the vertex mean (Average) as the intermediate fallback before the
NetTopologySuite InteriorPoint path — this benchmark quantifies the cost of that fallback.
| points | Average (µs/call) | Centroid (µs/call) | ratio (Centroid / Average) |
|---|---|---|---|
| 100 | 3.38 | 2.76 | 0.82× |
| 1,000 | 28.59 | 26.50 | 0.93× |
| 10,000 | 48.10 | 56.64 | 1.18× |
| 100,000 | 251.16 | 709.93 | 2.83× |
| 1,000,000 | 2,452.20 | 3,984.75 | 1.62× |
| points | Average (µs/call) | Centroid (µs/call) | ratio (Centroid / Average) |
|---|---|---|---|
| 100 | 1.33 | 2.42 | 1.82× |
| 1,000 | 12.83 | 22.80 | 1.78× |
| 10,000 | 129.11 | 224.17 | 1.74× |
| 100,000 | 1,304.80 | 2,629.66 | 2.02× |
| 1,000,000 | 12,171.55 | 20,470.20 | 1.68× |
-
Both scale linearly — per-call time grows ~10× for each 10× increase in point count, confirming
the
O(n)single-pass behaviour of both queries with no scale-dependent surprise. -
Averagedoes strictly less work per vertex — ~2 additions versus the shoelace sum's ~4 multiply-adds — and, for aList<Point2D>input, it also avoids the internalToArray()copy thatCentroidincurs (itspoint2Ds as Point2D[]array fast-path misses for aList). SoAverageis both fewer FLOPs and one fewerO(n)allocation. -
Debug (no JIT optimisation) shows that raw work gap uniformly:
Averageis ~1.7–2× faster at every size. -
Release (JIT-optimised) tells a more nuanced story. At tiny inputs (≤ 1 000 points) both finish
in a few µs and per-call enumeration / measurement overhead dominates, so the ratio is noise and
can even invert (~0.8–0.9×).
Average's advantage emerges in the mid-range (1.18× at 10 000, ~2.8× at 100 000) and stays positive at 1 000 000 (~1.5–2×), though that top row is the noisiest — only 2 timed repeats, run-to-run variance ≈ ±20%. -
Takeaway for
InternalPoint: on realistic polygons both centres are trivially cheap (single-digit µs), so the vertex mean is essentially free to try as an intermediate fallback. It is chosen there for correctness/robustness — being a convex combination of the vertices it lands strictly inside more often — and the modest constant-factor speed edge is a bonus, not the reason. The centroid is not slow; the ordering is a quality decision, and the timings just confirm the extra fallback adds negligible cost.
A fixed-size companion test, AverageAndCentroid_PerformanceComparison (same file, 10 000 points),
asserts both queries agree on the origin and that Average is not slower than Centroid beyond a
generous noise margin, guarding against a future regression that would invert this relationship.
File: Facts/Polyhedron.cs
Method measured:
DiGi.Geometry.Spatial.Query.IsClosed<TPolygonalFace3D>(Polyhedron<TPolygonalFace3D>, bool, double)
What changed. The original implementation reconstructed every face edge in world coordinates and
paired edges off against each other in a tolerance-sized spatial hash of edges, cancelling matched
pairs and declaring the polyhedron closed when the grid emptied. It was replaced by a two-stage pass
that welds face vertices into shared integer indices through a spatial hash of vertices, then counts
each edge by its (int, int) index pair — the same edge-counting scheme
DiGi.Geometry.Core.Classes.Mesh<TPoint>.IsClosed already used.
Editable knob. The extrusion size is the 500 passed to the private helper
Polyhedron_IsClosed_Extrusion(int count) in Facts/Polyhedron.cs; repeats in the same test
controls how many timed calls are averaged.
Old and new implementations measured A/B in a single process against the same polyhedron instances,
200 timed repeats for the extrusion and box, 20 for the ellipsoid. Allocation is
GC.GetAllocatedBytesForCurrentThread() deltas divided by the repeat count.
| Polyhedron | Faces | Old (µs) | New (µs) | Speed-up | Old (KB) | New (KB) | Allocation |
|---|---|---|---|---|---|---|---|
| Box | 6 | 5.6 | 3.0 | 1.87× | 16.2 | 13.0 | −20 % |
| 500-gon extrusion | 502 | 2 170 | 884 | 2.45× | 1 775 | 1 351 | −24 % |
| Ellipsoid 50 × 100 | 9 800 | 9 636 | 6 784 | 1.42× | 20 582 | 16 695 | −19 % |
A phase bisection of the new implementation on the 9 800-face ellipsoid, measured by disabling one stage at a time:
| Stage | Time (µs) | Allocation (KB) |
|---|---|---|
| Read every face through the public accessors, nothing else | 6 554 | 16 462 |
| + project ring points to world coordinates | 6 193 | 16 462 |
| + weld vertices and count edges (full method) | 7 639 | 16 695 |
- The matching phase is now essentially free — welding and edge counting together add ~230 KB, about 1.4 % of the total. Welding measured in isolation on precomputed coordinates costs 961 KB and ~2 ms for 29 400 input points welded down to 4 902 vertices.
-
Everything else is the per-face clone chain. Reading one face allocates through four cloning
accessors:
Planar<T>.Plane(a freshPlane, andPlane.AxisX/AxisYcost ~0.5 KB per face between them),Planar<T>.Geometry2D(a deep clone of the whole 2D face including everyPoint2D), andPolygonalFace2D.Edges(which clones every ring again). One of the four layers was removed here by reading points through the existing non-cloningSegmentable2D.GetPoints(bool clone)overload; the rest needGetX(bool clone)overloads onPlanar<T>/IPlanar/PolygonalFace2D/IPolygonalFace2Dand are deliberately out of scope. That is where any further gain has to come from — the algorithm itself is no longer the bottleneck. - Why the ellipsoid gains least. It is triangulated, so it has the highest face count for its vertex count and therefore the highest ratio of clone cost to matching cost. The extrusion, whose two 500-vertex end caps carry many edges per face read, gains most.
- The grid is only an accelerator. A match is always confirmed by an explicit squared-distance test, so an extreme coordinate-to-tolerance ratio can degrade the grid and cost time or report open, but can never fabricate a match and report a false closure. An earlier draft added a bounding-box pass to widen the tolerance against cell-index overflow; measurement showed it doubled allocation (31 MB against 16.7 MB per call) to guard a failure mode that cannot occur, and it was removed.
Polyhedron_IsClosed_Performance uses 200 repeats. At 20 repeats the measurement sits in tier-0
JIT code and reports a higher per-call cost in Release than in Debug (3 081 µs against 2 479 µs) —
a clear signal the run never reached steady state. Absolute numbers also shift by up to ~1.8× between
a standalone run of the fact and the same call inside the A/B harness, so treat the ratios in the
table above (measured under identical in-process conditions) as the meaningful result and the absolute
figures as environment-specific.
- Add a
[Fact]underFacts/inDiGi.Geometry.xUnit, following the repo's test conventions (partial class Facts, XML<summary>, explicit types, warm-up-then-Stopwatch). - Expose the sweep size(s) as a single
const/static readonlyfield at the top of the class so the benchmark can be scaled up for a stress run and back down for fast everyday runs. - Warm up (small input) before timing to exclude JIT cost.
- Assert cross-implementation agreement (result values / counts) so timing covers equivalent work.
- Run in Release for representative numbers, and capture the current machine spec.
- Record the machine spec, fully namespace-qualified method names, the result table(s), and the analysis on this page.
See also: the DiGi.ComputeSharp Benchmark page (GPU vs. CPU), which follows the same standard.