Repository navigation
Smooth by color class instead of under ring locks - #1079
Merged
Merged
Conversation
This was referenced Oct 7, 2026
danielepanozzo
added this pull request to stack #1082
October 7, 2026 13:59
Comment on lines
+163
to
+164
| for (size_t t = 0; t < nt; ++t) { | ||
| tg.run([&] { |
Contributor
Author
There was a problem hiding this comment.
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.
teseoch
approved these changes
Oct 7, 2026
danielepanozzo
removed this pull request from stack #1082
October 8, 2026 00:54
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
force-pushed
the
colored-smoothing
branch
from
October 8, 2026 03:28
f692266 to
e01ff12
Compare
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
…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
…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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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'son_bbox_faces. It only happened when topological_offset ran withnum_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_smoothingis now a JSON option in tetwild, triwild, simwild and topological_offset. It defaults to true; false brings back the locked pass.New tests:
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 undefinedabsl::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:
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.
smooth_vertex_3d. A non-constAttributeCollection::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.task_groupstarts 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.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.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; andIntegration_Testsall 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 unlockedTuple::is_validchecks in the queue path.🤖 Generated with Claude Code
https://claude.ai/code/session_01BJZ1VpHeewY9QZVM2AHWNU