Skip to content

Pool OutPt/OutRec/Active allocations and avoid repeated laps in CleanCollinear - #1117

Open
xaviergonz wants to merge 3 commits into
AngusJohnson:mainfrom
xaviergonz:perf/outpt-active-arena
Open

xaviergonz wants to merge 3 commits into
AngusJohnson:mainfrom
xaviergonz:perf/outpt-active-arena

Conversation

@xaviergonz

@xaviergonz xaviergonz commented Oct 15, 2026 •

Copy link
Copy Markdown

Hi Angus,

This is another one from profiling Clipper2 for a WebAssembly build. It's independent of #1115 and #1116, but it builds on the same work. After those two, offsets (open paths especially) were spending most of their remaining time on the allocator, plus one avoidable extra lap in CleanCollinear. There are three commits, which can be reviewed separately.

1. Allocate OutPt and Active objects from a per-clipper pool (C++, Delphi)

Every OutPt and every Active was its own heap allocation and got deleted one at a time. In an open-path offset, about 30% of the time was going to malloc/free.

The C# port already avoids this. It has OutPtPoolList for OutPts, and NewActive/PoolDeletedActive with a free list for Actives. This commit does the same in C++ and Delphi, using the same names where it makes sense:

  • OutPts come from a pool owned by ClipperBase (outpt_pool_ / FOutPtPool). They're all released together in CleanUp, so disposing of one during the sweep is just unlinking it.
  • Actives come from NewActive(). DeleteFromAEL hands them to PoolDeletedActive(), which clears them and puts them on a free list (free_actives_ / FFreeActives) for reuse. As in C#, that's a separate list, so the "already deleted" check in DeleteFromAEL still works.
  • The pool is a small chunked allocator, ObjectPool<T> in clipper.engine.h and TObjectPool in Delphi. Chunks grow from 32 to 4096 objects. I didn't use std::deque because MSVC's deque allocates objects this size one at a time, so it would gain nothing on Windows.

Nothing is global or shared between instances, and nothing is kept once Execute finishes.

2. Allocate OutRecs from a pool too (C++ only)

There's an OutRec for every local minima, so with lots of short output paths these allocations were still noticeable. They now come from an ObjectPool as well. DisposeAllOutRecs destroys each one (they all live in outrec_list_) before the pool is released. C# already pools these (OutRecPoolList). I tried the same in Delphi, but FPC's allocator is already fast for records this size and it made no measurable difference, so I left Delphi as it was.

3. Recheck only the affected vertices in CleanCollinear (all ports)

Each time CleanCollinear removed a vertex, it restarted its "full lap" end condition. On paths with lots of collinear vertices it ended up doing about two full laps; an open-path offset of a 5000-point circle walks around 33k vertices to remove 7.7k. But removing a vertex can only make its two neighbours removable, and the next one is checked straight away. So after the first lap, only the vertices before removed ones are rechecked, in the same order that further full laps would reach them. The result is exactly the same.

Numbers

These are on an M1 Max. Times are ms per operation, best of 5. The C++ numbers were measured with #1115 and #1116 already applied, and the Delphi numbers (FPC 3.2.2) against plain main.

C++ before C++ after Delphi before Delphi after
polygon, round, +5 (1000 pt) 0.064 0.048 0.191 0.094
polygon, round, +500 (1000 pt) 0.104 0.069 0.308 0.155
polygon, miter, −500 (5000 pt) 1.40 1.12 2.80 2.73
open path, square, +50 (5000 pt) 2.23 1.47 3.50 3.38
union, 128×256 separated 4.03 3.45 11.0 9.9
union, 256×256 overlapping 13.7 13.5 49.9 46.8

In C++, about 25% of the open-path improvement comes from commit 1, 10% from commit 2 and 3% from commit 3. Together with #1115 and #1116, C++ offsets are now about as fast as Clipper1's (the open-path square case is 0.98× of Clipper1, and the polygon cases are faster). In C#, commit 3 is within noise, since C# spends its time elsewhere.

The trade-off is that disposed OutPts aren't returned to the heap until the clip finishes. That's the same as C#'s OutPtPoolList, and they're a small fraction of the total.

Testing

  • The C++ test suite passes (96/96), including the Z builds.
  • A differential fuzz test against main gives identical output in C++, C# and Delphi, including after each commit on its own. It runs 20k random cases with all clip types, fill rules, PreserveCollinear on and off, open paths, PolyTree output and offsets. To check that it actually exercises the CleanCollinear change, I also tried a version that never rechecks, and the fuzz caught it.
  • The C++ fuzz is clean under ASan/UBSan, and FPC's heaptrc reports no leaks in Delphi.

Copilot AI balanced review requested due to automatic review settings October 15, 2026 06:20

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Every OutPt and Active was a separate heap allocation that was deleted
individually, and malloc/free ended up being a big chunk of the time
spent in offsets (lots of short lived OutPts) and open path clipping.

Now they're allocated from chunks owned by ClipperBase and all released
together in CleanUp, so deleting an OutPt is just unlinking it, and
deleted Actives go on a free list and get reused. This mirrors what the
C# port already does (OutPtPoolList, NewActive/PoolDeletedActive), so
only C++ and Delphi are changed. Output is unchanged.
@xaviergonz
xaviergonz force-pushed the perf/outpt-active-arena branch from 9e6cb12 to dd30e75 Compare October 15, 2026 18:15
@xaviergonz xaviergonz changed the title Allocate OutPt and Active objects from a per-clipper arena Allocate OutPt and Active objects from a per-clipper pool Oct 15, 2026
OutRecs are created for every local minima, so with lots of short output
paths (open path offsets especially) allocating and deleting them was
still a noticeable cost. They're now allocated from an ObjectPool like
OutPts, destroyed in DisposeAllOutRecs and freed together. C# already
pools these (OutRecPoolList). In Delphi, FPC's allocator is already fast
for records this size and pooling them made no difference.
Each time CleanCollinear removed a vertex, it restarted its "full lap"
end condition, so on paths with lots of collinear vertices it ended up
doing about two full laps. But removing a vertex can only make its two
neighbours removable, and the next one is checked straight away. So
after the first lap, only the vertices before removed ones are
rechecked, in the same order that further full laps would reach them.
The result is exactly the same.
@xaviergonz xaviergonz changed the title Allocate OutPt and Active objects from a per-clipper pool Pool OutPt/OutRec/Active allocations and avoid repeated laps in CleanCollinear Oct 15, 2026
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