Skip to content

Smooth by color class instead of under ring locks - #1079

Merged
danielepanozzo merged 8 commits into
mainfrom
colored-smoothing
Oct 8, 2026
Merged

danielepanozzo merged 8 commits into
mainfrom
colored-smoothing

Conversation

@danielepanozzo

@danielepanozzo danielepanozzo commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto #1084, which is now merged into main. The stack is main ← #1079 ← #1080 ← #1081, to be merged in that order. The measurements in the original description below predate the rebase (against main 54ed1a2); #1081 has the current numbers for the whole stack against #1084.

Review fixes (2026-10-08)

An independent review of the stack found one real problem in this PR, and some gaps. All are fixed in new commits:

  • topological_offset's smoothing hooks wrote neighbors back. Its front and plastic smoothers, and its invariants(), read neighboring vertices through non-const attribute access. Under colored smoothing, a rejected smooth wrote those neighbors back while another thread of the same class could be doing the same: a data race, and a possible double free of a vertex's on_bbox_faces. It only happened when topological_offset ran with num_threads > 0. Those reads now go through const access.

  • A check that makes this class of bug fail at once. In a colored pass, each smooth checks that everything it reached through non-const access is its own vertex, or an edge, face or cell touching it. If a hook reaches further, the pass throws immediately, at any thread count.

  • colored_smoothing is now a JSON option in tetwild, triwild, simwild and topological_offset. It defaults to true; false brings back the locked pass.

  • New tests:

    • thread-count determinism with a real surface, an envelope and unrounded vertices, in 3D and 2D;
    • the check firing on a hook that touches a neighbor;
    • unit tests for the coloring and the class runner.
  • Cleanups: an early-out when there is nothing to smooth, no barrier after the last class, an assert against duplicate vertices, and comments on how the barrier waits.

  • Build fix for Homebrew Macs. On such a Mac, FindGMP took GMP's include directory from the shared /opt/homebrew/include. That put every Homebrew-installed library ahead of the copies the build fetches. With Homebrew's Abseil installed, topological_offset and manifold_extraction compiled against its headers but linked ipc-toolkit's fetched Abseil. The link then failed on an undefined absl::lts_20260817::hash_internal::MixingHashState::kSeed. FindGMP now looks in GMP's own Homebrew prefix first, and drops a stale cached value, so existing build directories pick the fix up.

  • topological_offset tested in parallel. With the build fix, its 42 unit tests and manifold_extraction's tests pass. The five topological_offset integration configs run cleanly at 4 and 8 threads, on this PR alone and on the whole stack, with no error from the new check:

    • 2D: square, circle, triangle;
    • 3D: double sphere and convex.

    The two 3D configs in the data repository use keys the spec no longer accepts (offset_gradient_rel, offset_residual_rel, relative_ball_threshold), so they fail on any build. For these runs I dropped those keys and capped them at 3 turns.

  • The check costs nothing measurable. Smoothing time per operation with the check on, against off: 0.99× geomean over 4 tetwild and 2 triwild models, 16 threads, mirrored order.


Stack: #1079 (colored smoothing) ← #1080 (screening) ← #1081 (persistent worker pool). Merge in that order.

First of three stacked PRs that speed up the parallel optimization passes. This one replaces the ring locks of the parallel smoothing pass with a vertex coloring. #1080 (screening) builds on it, and #1081 (persistent worker pool) on that.

What changes

A parallel smoothing pass splits its vertices into classes of pairwise non-adjacent vertices and smooths the classes one after another. Each class runs fully in parallel with no locks: two vertices of a class share no edge and no cell, so smoothing one neither moves a vertex the other reads nor writes a cell attribute the other writes.

  • Deterministic. The coloring is greedy and serial in vertex order, so the classes, and with them the result, do not depend on the number of threads. New tetwild and triwild tests check it bit for bit at 1, 3, 8 and 16 threads.
  • Const neighbour reads in smooth_vertex_3d. A non-const AttributeCollection::operator[] inside the protect window snapshots the vertex it touches, and a rolled-back smooth writes every snapshot back. Here that means two threads of the same class writing back the same neighbour, which both of them read, at the same time: before this fix, 4 of 10 colored runs aborted.
  • One parallel region per pass. The classes meet at a barrier instead of each starting its own threads. task_group starts a thread per task, and a thread's thread-local Newton solver lives only as long as the thread, so the threads are started once per pass, as the locked pass started them.
  • Load balance within a class. A thread takes about an eighth of its share at a time, surface vertices first. Those project onto the envelope and cost the most, so a class does not wait at its barrier for one expensive chunk.

OptimizerParameters::colored_smoothing (default on) switches it off. Serial runs keep the single queue and stay byte-identical to main.

12 files, +668/-12, of which ~255 lines are the two tests.

Measurements

16 threads, Apple M3 Max, against main 54ed1a2. The challenging models (the [challenging] integration group, run here at 16 threads instead of serially), 2 runs per model in mirrored order. Some background indexing ran during parts of the benchmark.

main this PR speedup
tetwild, 14 models, total wall 685 s 634 s 1.08x (geomean 1.10x)
tetwild, smoothing passes 193 s 137 s 1.41x
triwild, 16 models, total wall 210 s 202 s 1.04x (geomean 1.02x)
triwild, smoothing passes 60 s 50 s 1.19x

The wall-time gain is modest on its own: smoothing is a third of tetwild's optimization time, and several models land on a different iteration count than main (1017020: 0.85x, 1017012: 0.87x). The pass times above are the cleaner measure. All runs reach stop energy 10, and the final max/avg energies and cell counts are within main's run-to-run spread.

Tests

wmtk_tests; the tetwild, triwild, simwild, isotropic_remeshing, qslim and shortest_edge_collapse component tests; and Integration_Tests all pass. The isotropic remeshing assertion count is identical to main's (352). Serial output is byte-identical to main (tetwild 101954, triwild 191265). ThreadSanitizer on tetwild 101955 and triwild 194286 reports nothing from the coloring code: the only reports are main's existing unlocked Tuple::is_valid checks in the queue path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU

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 encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

🟡 Changes recommended

Partial worker-launch failure can permanently deadlock the color-class barrier.

1 open finding

🧠 Review effort: Balanced

Comment thread src/wmtk/utils/VertexColoring.hpp Outdated
Comment on lines +163 to +164
for (size_t t = 0; t < nt; ++t) {
tg.run([&] {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in "Color classes: start no task before every task is launched": tasks now wait at a start gate until every launch has succeeded. If one launch throws, the tasks already running are told to leave without touching the barrier, and the launch failure is rethrown. There's no test for this path, since making task_group::run fail would need a test-only hook.

@danielepanozzo
danielepanozzo removed this pull request from stack #1082 October 8, 2026 00:54
danielepanozzo and others added 2 commits October 7, 2026 23:17
A parallel smoothing pass now splits the vertices into classes of pairwise
non-adjacent vertices (a greedy coloring, serial in vertex order, so the same
classes on any number of threads) and smooths the classes one after another,
each fully in parallel with no locks. Two vertices of a class share no edge
and no cell: smoothing one neither moves a vertex the other reads nor writes a
cell attribute the other writes. The result does not depend on the number of
threads; new tetwild and triwild tests check it bit for bit at 1/3/8/16.

- Neighbour reads in smooth_vertex_3d go through const access. A non-const
  AttributeCollection::operator[] inside the protect window snapshots the
  neighbour, and a rolled-back smooth writes the snapshot back -- over a
  neighbour another thread of the class just smoothed (4 of 10 runs aborted
  before this).
- The classes run in one parallel region per pass, meeting at a barrier
  between classes: task_group starts a thread per task, and a thread keeps its
  thread-local Newton solver only as long as it lives -- started once per
  pass, as the locked pass started them.
- Within a class a thread takes about one eighth of its share at a time,
  surface vertices first (they project onto the envelope and cost the most),
  so a class does not wait at its barrier for one expensive chunk.

OptimizerParameters::colored_smoothing (default on) switches it off; serial
runs keep the single queue and stay byte-identical to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
The class barrier waits for all num_threads tasks. If launching one of them
failed -- task_group::run throwing because a thread could not be created --
the tasks already running waited at the first barrier for one that never
came, and task_group's destructor, joining them during unwinding, hung.

Tasks now wait at a start gate until every launch has succeeded. If one
fails, the gate tells the running tasks to leave without touching the
barrier, and the launch failure is rethrown.

Addresses Copilot's review comment on VertexColoring.hpp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
@danielepanozzo
danielepanozzo changed the base branch from main to danielepanozzo/tracked-surface-orientation October 8, 2026 03:29
Base automatically changed from danielepanozzo/tracked-surface-orientation to main October 8, 2026 04:00
danielepanozzo and others added 4 commits October 8, 2026 00:06
…hing hooks

Colored smoothing runs non-adjacent vertices concurrently without locks. Two
of them can share a neighbour, and an attribute a smoothing hook reaches
through non-const access inside the smooth is recorded and written back when
the smooth is rejected -- by two threads at once. For on_bbox_faces, a
std::vector, that is a double free.

Five reads did that:
- the containment check of the 3D front smoother's normal-only path
  (FrontSmooth3d.cpp);
- the 2D front smoother's containment check (FrontSmooth2d.cpp);
- the 2D plastic smoother's rest cells (Optimize2d.cpp);
- invariants() in 2D and 3D, which a smooth also runs.

They now read through std::as_const or the const smoothing_position(). The
other hooks were audited: they only touch the smoothed vertex, its star, or
collections that are read through const member functions.

Found by the independent review of #1079. topological_offset runs serially by
default, so only runs with num_threads > 0 were exposed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
Two vertices of a color class share no simplex of their stars, but they can
share a neighbour. A smoothing hook that reaches anything outside the star
through non-const attribute access has it recorded, and a rejected smooth
writes every recorded entry back: two threads write the same neighbour at
once. The base hooks read their neighbours through const access, but nothing
stopped a derived mesh from doing otherwise -- topological_offset did.

- AbstractAttributeContainer::append_recorded() lists the entries the
  thread has recorded since begin_protect().
- While TetMesh/TriMesh::m_check_smoothing_stays_in_star is set,
  smooth_vertex() checks that every recorded vertex, edge, face and cell is
  the smoothed vertex or incident to it. If not, it drops the record without
  writing it back and throws, naming both. This runs on both paths, whether
  the smooth was accepted or rejected.
- utils::smooth_classes_checked() runs a colored sweep with the check on;
  both drivers use it. A bad hook now fails on its first such read, at any
  number of threads, instead of racing rarely. The check costs a few small
  vectors per smooth, next to a Newton solve.
- The smoothing hooks (TetOptimizerMesh/TriOptimizerMesh::smooth_after) and
  OptimizerParameters::colored_smoothing now state the contract. The comments
  that claimed it held for every mesh now say it holds for the base classes.

Small fixes from the same review:
- for_each_in_classes returns early when there is no work, and skips the
  barrier after the last class.
- Its doc explains why the barrier yields instead of blocking, and that it
  needs every task running at once (task_group gives each its own thread).
- greedy_vertex_coloring asserts that no vertex is listed twice.
- An unused include is dropped, and the dynamic_parallel_for comment no
  longer cites dry runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
OptimizerParameters::colored_smoothing (default true) could not be set from
a JSON config, so there was no way back to the locked smoothing pass. It is
now a key in tetwild, triwild, simwild and topological_offset, next to
interleaved_smoothing, and each parser reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
…ts check

The existing determinism tests smooth a jittered grid: no surface, no
envelope, every vertex rounded. That skips the paths most likely to break.

- tetwild: a slanted closed surface goes through the serial pipeline
  (VolumeRemesher insertion), and every fifth rounded interior vertex is
  taken off the double grid. Two passes on 1, 4 and 16 threads then give the
  same mesh bit for bit, and they round some of those vertices. The pass
  smooths about 5,000 vertices, about 150 of them refused by the envelope.
- triwild: the same for a slanted closed curve cut by a chord, embedded and
  refined by one serial optimization round.
- Both: a hook that reads a neighbour through non-const access makes the
  colored pass throw, on 1 and on 4 threads. The locked pass, which does not
  check, still runs it.
- wmtk_tests ([coloring]): greedy_vertex_coloring puts every vertex in
  exactly one class, in input order, with no two adjacent vertices together,
  and gives the same classes on any number of threads; uncolored vertices do
  not constrain it. for_each_in_classes visits each vertex once and finishes
  a class before starting the next. It rethrows a task's exception and
  handles empty input.
- The existing tests reserve their Run vector: a mesh holds a reference to
  its Run's Parameters, which a reallocation would leave dangling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
danielepanozzo added a commit that referenced this pull request Oct 8, 2026
Brings in #1079's and #1080's review fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
danielepanozzo and others added 2 commits October 8, 2026 10:09
…lude directory

Found through the default search, GMP's include directory on a Homebrew Mac is
/opt/homebrew/include, where Homebrew links every formula's headers. It reaches
everything that links GMP as a system include directory, ahead of the
dependencies the build fetches, so any library Homebrew has installed shadows
the fetched copy. With Homebrew's Abseil (LTS 20260817) installed,
topological_offset and manifold_extraction compiled against those headers but
linked the Abseil that ipc-toolkit fetches (LTS 20260107). The link then failed
on an undefined absl::lts_20260817::hash_internal::MixingHashState::kSeed.

FindGMP now looks first in the formula's own prefix (<prefix>/opt/gmp), which
holds GMP's headers alone, and drops a cached GMP_INCLUDES that points at the
shared directory, so existing build directories pick the fix up when CMake
reruns. With it, both components build and their tests pass on such a machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
@danielepanozzo
danielepanozzo merged commit 6f6ebb9 into main Oct 8, 2026
16 of 18 checks passed
@danielepanozzo
danielepanozzo deleted the colored-smoothing branch October 8, 2026 21:25
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.

3 participants