Fix thread-safety bug in geom.c - #75
Conversation
This changes the semantics. But it's probably a good idea anyway since |
This is potentially dangerous for a large prism because stack space is limited. In particular, it could crash for prisms more than about 100,000 elements. |
|
For the prism list, in practice the
|
|
I implemented my proposal and pushed a commit to this PR. |
|
The latest changes (c617512) are producing failures for the Meep unit test
|
intersect_line_segment_with_mesh added (b - last_s) when a hit fell at or past b, then the post-loop cleanup added the same length again because inside/last_s were not updated in the break branch. Drop the in-branch addition and let the post-loop check handle the inside-at-b case. Add test_cube_segments_vs_block: 1000 random unit-direction line segments through a unit cube mesh vs make_block, comparing interior lengths within 1e-4. This test surfaced the bug; max diff after the fix is ~4e-16. Also note that remove_duplicate_intersections should be unified with the prism slist dedup after NanoComp#75.
|
This PR is ready to be merged. The errors I reported in my previous comment should be ignored as I discovered that the local build of Meep I was using for testing was linking to a different version of I verified that the previously failing tests are now passing with this PR. |
|
We now need a PR enabling |
…#81) * Fix init_prism centroid + remove racy per-query auto-fix from query paths Two related bugs surfaced by running the --enable-openmp test suite against test-prism, both pre-existing: 1) init_prism assigned prsm->centroid BEFORE the shift-to-o->center logic, then the shift only updated a local `centroid` variable. After construction, any prism whose center was modified ended up with prsm->centroid stale relative to the (shifted) vertices. 2) point_in_objectp, normal_to_object, and point_in_periodic_objectp each called geom_fix_object_ptr(&o) on every query. For prisms that path calls reinit_prism, which free()s + malloc()s shared per-prism arrays (vertices_p, vertices_top_p, ...). Under OpenMP the test was crashing with "malloc(): unaligned tcache chunk" / "free(): double free". This matches what PR #75 already did for geom_get_bounding_box -- callers are now responsible for calling geom_fix_object_ptr after mutating o.center. test-prism: after the random-shift branch, call geom_fix_object_ptr on the block and prism once, single-threaded, before the parallel test loops run. Verified at OMP_NUM_THREADS = 1, 2, 4, 8, 16, 32, 64, 128, 166: all clean (0 point failures, 0 segment failures, no heap corruption). make check passes. * Restore auto-fix in query wrappers; document them as not thread-safe Per review feedback, point_in_objectp / normal_to_object / point_in_periodic_objectp are restored to their original behavior (call geom_fix_object_ptr internally) rather than being silently turned into thin aliases for the *_fixed_* variants. Their doc comments now state they are NOT thread-safe and direct concurrent callers at the *_fixed_* entry points. test-prism's parallel loops switched from point_in_objectp / normal_to_object to point_in_fixed_objectp / normal_to_fixed_object, which are pure reads and safe to call from multiple threads against an object that was fixed up once beforehand. Combined with the init_prism centroid fix in the previous commit, make check passes cleanly across OMP_NUM_THREADS = 1, 2, 4, 8, 16, 64, 166 (21 trials each: 0 point-inclusion failures, 0 segment failures, no heap-corruption signatures).
Mirror the test-prism.c thread-safety verification (PR NanoComp#75 / commit 926527c): parallelize the two random-comparison loops in test-mesh.c that already use the pure _fixed_* query variants: - test_cube_vs_block (10000 random points, point_in_fixed_pobjectp) - test_cube_segments_vs_block (1000 random segments, intersect_line_segment_with_object — for MESH this dispatches directly to intersect_line_segment_with_mesh, which uses a stack-local mesh_hit_list with per-call heap fallback) Both objects (mesh + block) are constructed on the main thread before the parallel region, so init_mesh / BVH build happens once, single-threaded. After that, every query path through point_in_mesh, normal_to_mesh, find_closest_face, mesh_ray_all_intersections, ray_triangle_intersect, ray_bvh_node_intersect, and BVH traversal is a pure read against the mesh struct — no shared mutable state, no module-level statics (the only static module data is the const mesh_ray_dirs table). rand() is replaced with rand_r() seeded per iteration so results are deterministic across thread counts; this lets the assertions (0 mismatches, max_diff at machine precision) hold uniformly in serial and parallel runs. Verified with --enable-openmp at OMP_NUM_THREADS = 1, 2, 4, 8, 16, 64, 166: identical results (0 mismatches, max_diff = 4.44e-16) at every thread count, no heap corruption. 20 repeated trials at 64 threads: all clean.
* Add MESH geometric object type for arbitrary triangulated 3D surfaces Implement a new MESH primitive alongside SPHERE, CYLINDER, BLOCK, and PRISM. A mesh is defined by a vertex array and triangle index array, with a BVH (bounding volume hierarchy) for O(log N) queries. Core operations implemented: - point_in_mesh: ray casting with parity counting and deduplication - normal_to_mesh: BVH-accelerated closest-face search - get_mesh_bounding_box: from BVH root node - get_mesh_volume: divergence theorem (signed tetrahedron volumes) - intersect_line_segment_with_mesh: for subpixel smoothing integration - init_mesh: validates geometry, computes normals, fixes winding, builds BVH BVH uses SAH (surface area heuristic) binned construction with flat array layout. Ray-triangle intersection uses Moller-Trumbore algorithm. Closest-point queries use Eberly's algorithm with BVH pruning. Also adds make_mesh/make_mesh_with_center constructors, mesh copy/equal/destroy in geom-ctl-io.c, and 11 unit tests covering point-in, volume, bbox, normals, line-segment intersection, and comparison against the Block primitive. * Fix point_in_mesh robustness and reinit_mesh performance Two fixes: 1. point_in_mesh: use 3-ray majority vote instead of a single ray. A single ray can pass through a shared mesh edge, causing the deduplication to either miss or double-count a crossing and flip the inside/outside parity. Three rays in irrational directions make it extremely unlikely that more than one hits a degenerate case at the same query point. 2. reinit_mesh: skip BVH rebuild if already initialized. Unlike blocks/cylinders, mesh uses absolute coordinates and doesn't depend on the lattice basis. geom_fix_object_ptr is called hundreds of times during meep's init_sim via geometry copies; without the guard, each call rebuilt the BVH (~150ms for 9K triangles), causing ~40s of wasted time. * Lazy majority vote and mesh closure validation - point_in_mesh: cast 1 ray first, only fall back to 3-ray majority vote if deduplication removed near-duplicate t-values (indicating an edge/vertex hit). 3x faster for the common case (no edge hits). - init_mesh: validate mesh closure by building an edge-to-face-count hash table. Every edge must be shared by exactly 2 faces. Sets is_closed=false and prints a warning for open/non-manifold meshes. point_in_mesh returns false for all points on open meshes. - Add 2 new tests: open mesh detection, closed mesh detection (13 tests total, all passing). * Add closure validation edge case tests Test boundary edges (1 face per edge), non-manifold edges (4 faces sharing an edge), and isolated vertices (vertex unreferenced by any face). 16 tests total, all passing. * Eliminate redundant BVH build and point_in_mesh fallback 1. make_mesh_with_center: shift vertices before calling init_mesh so the BVH is only built once (was building twice when center was specified). 2. intersect_line_segment_with_mesh: determine inside/outside at the segment start from the full intersection list parity (count crossings before parameter 'a') instead of calling point_in_mesh as a fallback. Eliminates millions of extra ray casts during subpixel smoothing. ~20% faster set_epsilon on the Utah teapot (28.6s → 22.9s). * Minor cleanups: remove dead code, optimize BVH queries - Remove unused bvh_node_box helper function - find_closest_face: push nearer BVH child last so it's popped first, improving pruning and reducing nodes visited for normal queries - Extract mesh_triangle_vertices helper to avoid redundant vertex fetches. Add mesh_triangle_bbox_centroid to compute both in one pass during BVH binning (was fetching 3 vertices twice per face) * Add mesh API documentation to ctlgeom.h * Include generated ctlgeom-types.h and geom-ctl-io.c These files are normally auto-generated from geom.scm via gen-ctl-io (requires Guile). Including them allows building libctlgeom without Guile, which is the common case for meep's Python-only builds. * Address PR feedback: relative lengthscale tolerances and mesh input documentation * Fix 3 bugs found in code review: per-component winding, dynamic ray-hit buffer, vertex mutation rebuild * Drop vertex_hash mutation detection in reinit_mesh A non-NULL bvh is sufficient to skip rebuild: there is no documented way for callers to mutate mesh vertices post-construction, and none of our internal functions do so. Hash-based detection was also unsound on collisions. Removes the mesh_vertex_hash function, the vertex_hash field, and the now-irrelevant test_vertex_mutation case. * Fix double-counted interior length in mesh segment intersection intersect_line_segment_with_mesh added (b - last_s) when a hit fell at or past b, then the post-loop cleanup added the same length again because inside/last_s were not updated in the break branch. Drop the in-branch addition and let the post-loop check handle the inside-at-b case. Add test_cube_segments_vs_block: 1000 random unit-direction line segments through a unit cube mesh vs make_block, comparing interior lengths within 1e-4. This test surfaced the bug; max diff after the fix is ~4e-16. Also note that remove_duplicate_intersections should be unified with the prism slist dedup after #75. * Verify mesh thread-safety via parallel test loops Mirror the test-prism.c thread-safety verification (PR #75 / commit 926527c): parallelize the two random-comparison loops in test-mesh.c that already use the pure _fixed_* query variants: - test_cube_vs_block (10000 random points, point_in_fixed_pobjectp) - test_cube_segments_vs_block (1000 random segments, intersect_line_segment_with_object — for MESH this dispatches directly to intersect_line_segment_with_mesh, which uses a stack-local mesh_hit_list with per-call heap fallback) Both objects (mesh + block) are constructed on the main thread before the parallel region, so init_mesh / BVH build happens once, single-threaded. After that, every query path through point_in_mesh, normal_to_mesh, find_closest_face, mesh_ray_all_intersections, ray_triangle_intersect, ray_bvh_node_intersect, and BVH traversal is a pure read against the mesh struct — no shared mutable state, no module-level statics (the only static module data is the const mesh_ray_dirs table). rand() is replaced with rand_r() seeded per iteration so results are deterministic across thread counts; this lets the assertions (0 mismatches, max_diff at machine precision) hold uniformly in serial and parallel runs. Verified with --enable-openmp at OMP_NUM_THREADS = 1, 2, 4, 8, 16, 64, 166: identical results (0 mismatches, max_diff = 4.44e-16) at every thread count, no heap corruption. 20 repeated trials at 64 threads: all clean. * Apply suggestion from @stevengj --------- Co-authored-by: Steven G. Johnson <stevenj@mit.edu>
Drops the macos-latest matrix entry per maintainer preference (less useful: project is primarily Linux-targeted, macOS runners cost more minutes, and the existing Travis macOS coverage didn't exercise anything Linux didn't). Adds --enable-openmp to the configure step and matrices the run on OMP_NUM_THREADS = 1 and 8. Both values share the same build; only the runtime thread count differs. This catches regressions in the parallel test paths (test-prism's #pragma omp parallel for loops in test_point_inclusion, test_normal_to_object, and test_line_segment_intersection) that a serial-only run wouldn't flag — same matrix philosophy used to validate the prism thread-safety work in PR NanoComp#75 / NanoComp#81.
Drops the macos-latest matrix entry per maintainer preference (less useful: project is primarily Linux-targeted, macOS runners cost more minutes, and the existing Travis macOS coverage didn't exercise anything Linux didn't). Adds --enable-openmp to the configure step and matrices the run on OMP_NUM_THREADS = 1 and 8. Both values share the same build; only the runtime thread count differs. This catches regressions in the parallel test paths (test-prism's #pragma omp parallel for loops in test_point_inclusion, test_normal_to_object, and test_line_segment_intersection) that a serial-only run wouldn't flag — same matrix philosophy used to validate the prism thread-safety work in PR NanoComp#75 / NanoComp#81.
* Migrate CI from Travis to GitHub Actions
Replaces the Travis configuration with an equivalent GitHub Actions
workflow at .github/workflows/ci.yml. Same matrix (ubuntu-latest +
macos-latest), same build pipeline (autogen.sh, make, make check,
make install), same on-failure log dump for utils/*.log.
Two minor modernizations:
- Switches the Ubuntu guile package from guile-2.0-dev to
guile-3.0-dev. guile-2.0 was dropped from Ubuntu after 20.04;
libctl already builds and runs against guile 3.0.
- macOS dependency install adds autoconf/automake/libtool explicitly
rather than relying on the runner image to provide them.
Workflow triggers on push to master, pull_request to master, and
manual workflow_dispatch. fail-fast disabled so a failure on one OS
doesn't cancel the other.
Also removes the Travis CI badge from README.md and replaces it with
a GitHub Actions badge pointing at the new workflow.
* CI: drop macOS, add OMP_NUM_THREADS matrix to exercise parallel tests
Drops the macos-latest matrix entry per maintainer preference (less
useful: project is primarily Linux-targeted, macOS runners cost more
minutes, and the existing Travis macOS coverage didn't exercise
anything Linux didn't).
Adds --enable-openmp to the configure step and matrices the run on
OMP_NUM_THREADS = 1 and 8. Both values share the same build; only
the runtime thread count differs. This catches regressions in the
parallel test paths (test-prism's #pragma omp parallel for loops in
test_point_inclusion, test_normal_to_object, and
test_line_segment_intersection) that a serial-only run wouldn't
flag — same matrix philosophy used to validate the prism
thread-safety work in PR #75 / #81.
PR NanoComp#74 committed both ctlgeom-types.h and geom-ctl-io.c, but the versions it shipped were generated from a pre-PR-NanoComp#75 geom.scm that still had the prism workspace field. After NanoComp#75 removed workspace from geom.scm and from the C code in geom.c, the committed copies became stale. Without-Guile builds silently kept compiling because the two stale files were consistent with each other (prism_copy/destroy/equal operated on a workspace field that nothing else touched). With-Guile builds papered over it by regenerating ctlgeom-types.h to match the post-NanoComp#75 geom.scm, which silently differed from the committed copy. The previous commit (treat geom-ctl-io.c as checked-in) exposed the inconsistency by removing the regeneration safety net: with WITH_GUILE=true, ctlgeom-types.h gets regenerated fresh from geom.scm (no workspace, no number_list typedef), while the committed geom-ctl-io.c still tried to access prism->workspace, producing 'has no member named workspace' compile errors in prism_copy / prism_equal / prism_destroy. This commit aligns both committed files with current geom.scm: - ctlgeom-types.h: replaced with the gen-ctl-io output. Drops the unused number_list typedef and the workspace field from prism struct. Same mesh struct as before (vertices, face_indices, is_closed, internal). - geom-ctl-io.c: removed the stale workspace handling from prism_copy / prism_equal / prism_destroy. Kept the PIMPL hand-tweaks (extern mesh_init_internal / mesh_internal_free + their calls in mesh_copy / mesh_destroy) since the file is no longer auto-regenerated. Verified locally in both build modes: - WITHOUT Guile (WITH_GUILE=false, no regen): make + make check + OMP=1/8 clean. - WITH Guile (WITH_GUILE=true, ctlgeom-types.h regenerates and matches committed): make + make install + examples/ build (which exercises the cp utils/geom.c -> examples/geom.c MPB-style copy path) + make check + OMP=1/8 clean.
Root cause: Two thread-safety bugs in
utils/geom.c, exposed by Meep's recently added OMP parallization ofset_chi1inv(NanoComp/meep#3166).Fix 1 (primary — caused the crash of
test_mode_coeffs.py): Remove thegeom_fix_object_ptr(&o)call fromgeom_get_bounding_box(). For Prism objects, this call triggeredreinit_prism(), which frees and re-mallocs all internal prism arrays. When multiple OMP threads call this concurrently on the same Prism, it causes double-free/use-after-free, corrupting heap metadata and producing thefree(): unaligned chunk detected in tcache 2error.Fix 2 (secondary — would cause incorrect results): Change
intersect_line_segment_with_prism()to use a stack-allocated variable-length array (VLA) (double slist[num_vertices + 2]) instead of the sharedprsm->workspace.itemsbuffer. This prevents data races during concurrent intersection calculations.Verification: all 6 tests in Meep's
test_mode_coeffs.pynow pass withOMP_NUM_THREADS=1,2, and4.@Luochenghuang