Skip to content

countPinched: flat buffers instead of a map and a set per vertex - #179

Merged
revarbat merged 1 commit into
mainfrom
perf/checkmesh-pinched-allocations
Sep 16, 2026
Merged

revarbat merged 1 commit into
mainfrom
perf/checkmesh-pinched-allocations

Conversation

@revarbat

Copy link
Copy Markdown
Member

Stacked on #178 (based on perf/export-split-overlap-only), so review that one first.

What

With #177 fixed, I profiled what was left of export. For a single-mesh format the split is:

Dalek (119 bodies, 224k facets):   merge 494ms   strip 0.9ms   check 103ms
100 spheres (212k facets):         merge 725ms   strip 1.3ms   check 154ms

The merge is Manifold's own BatchBoolean and is real work. stripSlivers is already negligible. Inside checkMesh, though:

scan 9.6   faces 4.4   edges 15.2   pinched 65.2   unwelded 7.6     (Dalek)
scan 12.8  faces 6.9   edges 25.6   pinched 96.3   unwelded 10.7    (100 spheres)

countPinched is 63% of the check.

Why it was slow

It builds a std::unordered_map<Vert, std::vector<Vert>> and a std::set<Vert> fresh for each vertex, to answer a question about roughly six neighbours. That is on the order of a dozen allocations per vertex — millions on a 200K-triangle mesh — for a graph that fits in a cache line.

This is the same problem the file already documents fixing one function below:

Collected flat and sorted afterwards rather than inserted into a std::map/std::set, which allocated a tree node per EDGE and per FACE — on a 295K-triangle model that is ~885,000 plus ~295,000 allocations to compute a handful of counters.

The edge and face counts got that treatment. The vertex link did not.

The change

  • Buffers hoisted out of the loop, cleared per vertex.
  • The link is a flat node list with linear lookup. At degree ~6 a scan beats hashing outright, and allocates nothing.
  • Connectivity by union-find with path halving, instead of a std::set-based walk.
  • vertTris was a vector-of-vectors — one allocation per vertex, only ever read back as a flat run. It is CSR now: counted during the scan that was already running, filled in one pass afterwards.

Results

countPinched in isolation:

before after
Dalek 65.2 ms 7.3 ms 8.9x
100 spheres 96.3 ms 8.2 ms 11.7x

Whole export, on top of #178:

STL 3MF
Dalek 0.611 → 0.546 s 2.06 → 1.93 s
100 spheres 0.895 → 0.797 s 1.83 → 1.63 s

It also speeds up every import() of a mesh and polyhedron()'s manifoldness probe, both of which call checkMesh.

Output is unchanged

checkMesh only produces warning text; it never touches geometry. Verified rather than assumed: STL bytes, object counts, triangle counts and the 3MF mesh hash are identical with and without this change across Dalek, CosBox, Anklet and the 100-sphere model.

Tests

MeshCheck.ThreeTetrahedraOnOneVertexAreStillOnePinchedVertex pins the one thing the rewrite could plausibly get wrong: the count is of pinched vertices, so a link in three pieces is still 1. An implementation that counted pieces instead would return 2 and still agree with the existing two-piece test by luck.

It passes on the old code too — an equivalence guard, not a bug it caught. The speed claim rests on the isolated measurements above.

Full C++ suite: 1263 passed. Python bindings: 42 passed.

🤖 Generated with Claude Code

@revarbat
revarbat changed the base branch from perf/export-split-overlap-only to main September 16, 2026 00:51
checkMesh walks the link of every vertex to find pinches. It did that by
building a std::unordered_map of std::vectors and a std::set FRESH FOR EACH
VERTEX, to answer a question about roughly six neighbours -- on the order
of a dozen allocations per vertex, millions on a 200K-triangle mesh.

The buffers are now hoisted out of the loop and cleared per vertex, the
link is a flat node list with linear lookup (a scan beats hashing outright
at that size, and allocates nothing), and connectivity is union-find with
path halving rather than a set-based walk.

`vertTris` went the same way: it was a vector-of-vectors, one allocation
per vertex, and it is only ever read back as a flat run. It is CSR now,
counted during the scan that was already running and filled in one pass
afterwards.

This is the same fix the file's own comment describes applying to the edge
and face counts -- "allocated a tree node per EDGE and per FACE ... to
compute a handful of counters" -- which left the vertex link untouched.

countPinched, measured in isolation:

           before     after
  Dalek    65.2ms     7.3ms     8.9x
  100      96.3ms     8.2ms    11.7x

It was 63% of checkMesh. Whole-export effect, on top of #177:

               stl              3mf
  Dalek        0.611 -> 0.546   2.06 -> 1.93
  100 spheres  0.895 -> 0.797   1.83 -> 1.63

Export output is byte-identical -- checkMesh only produces warning text and
never touches geometry -- verified by fingerprinting STL bytes and the 3MF
mesh with and without this change.

ThreeTetrahedraOnOneVertexAreStillOnePinchedVertex pins the one thing the
rewrite could plausibly get wrong: the count is of pinched VERTICES, so a
link in three pieces is still 1, and an implementation that counted pieces
would agree with the existing two-piece test by luck. It passes on the old
code too; it is an equivalence guard, not a bug it caught.
@revarbat
revarbat force-pushed the perf/checkmesh-pinched-allocations branch from c4710c5 to 069e8fa Compare September 16, 2026 00:52
@revarbat
revarbat merged commit 16daf19 into main Sep 16, 2026
3 checks passed
@revarbat
revarbat deleted the perf/checkmesh-pinched-allocations branch September 16, 2026 01:16
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.

1 participant