DCEL: rewrite MeshT to drop its owned Pool pointer for device-mirror safety - #140
DCEL: rewrite MeshT to drop its owned Pool pointer for device-mirror safety#140rmrsk wants to merge 11 commits into
Conversation
MeshT<T, Meta> now requires an external, caller-owned Pool at construction and stores its vertices/edges/faces as PODVector<T> reserved from that Pool instead of std::vector, replacing the getVertices()/getEdges()/ getFaces() container accessors with a two-phase reserveX()/addX()/getX()/ numX() build API. deepCopy() takes an explicit destination Pool. Soup::soupToDCEL builds meshes via the same reserve-then-add discipline, and every Parser::readInto*/convertToDCEL entry point now takes an explicit Pool& (no hidden default pool), threaded through to every Examples/Integrations/Tests consumer. Pool itself is unchanged -- no free/release functionality was added. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
readIntoDCEL now returns a valid, empty MeshT (0 faces, signedDistance/ unsignedDistance2 correctly report +infinity) for an unrecognized file extension, matching dev's pre-PR3 behavior, instead of a nullptr that none of readIntoMesh/readIntoPackedBVH/readIntoTriangleBVH/ readIntoTriangles null-checked before dereferencing. Added a regression test locking in the non-null return. Also documents that a Pool must not be moved once a MeshT has been built into it, since MeshT holds a raw pointer to the Pool object itself, not just its underlying block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
…safety MeshT previously cached a Pool* set at construction and dereferenced it from every query, which is safe on the host but breaks the moment the mesh is mirrored to a device: the cached pointer only resolves in the address space it was set in, and a mirrored Pool object is itself still host-resident. MeshT now stores no pointer to the allocator at all -- only a resolved void* base, set once via bind() against a frozen Pool. Every method that needs to resolve a vertex/edge/face index gets a matching pair of overloads: an explicit-base form (valid at any point, including mid-build before a Pool is frozen) and a no-argument form that resolves against the bound base, for convenient querying once building is finished. This mirrors the PODVector at()-vs-bind() convention already established elsewhere in the memory foundation. Soup::soupToDCEL/reconcilePairEdgesDCEL, the STL/PLY/OBJ/VTK convertToDCEL entry points, and Parser::readIntoDCEL now build purely against an explicit Pool&, never freezing it -- a Pool may still be shared with more files. FlatMeshSDF/MeshSDF's constructors are the actual freeze+bind boundary (retaining a mesh for long-term querying is the point at which building must be considered finished); TriMeshSDF's mesh-based constructor deliberately does neither, since it only reads the mesh once and does not retain it. Parser's multi-file readIntoMesh/readIntoPackedBVH build every file's mesh before wrapping any of them, since wrapping the first would otherwise freeze the shared Pool out from under the rest. Adds a Sphinx "Memory model" page documenting Pool/PODVector/MemoryResource, and a matching subsection on ImplemDCEL.rst for how MeshT is built on top of it, including the sharing/freezing pitfalls this change introduces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
|
@claude review Generated by Claude Code |
|
Claude finished @rmrsk's task in 3m 57s —— View job Claude finished reviewing PR #140Tasks
(Note: could not run a local build/test — SummaryThis is a clean, well-executed rewrite. The explicit-base vs. bound-accessor split is applied consistently across Finding:
|
Not CI-tested, so this was missed until @claude review caught it: MeshSDF's constructor now takes (mesh, pool, build), and the mesh needs to be frozen and bound before mesh->getFace(i) (a no-argument accessor) is usable -- readIntoDCEL no longer does that automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
|
Good catch — fixed in 5655310. Generated by Claude Code |
…comments
MeshT had zero EBGEOMETRY_HOST/EBGEOMETRY_HOST_DEVICE annotations, unlike the
rest of the memory foundation (Pool, PODVector) and the already-annotated
value types (Vec, BoundingVolumes). Annotates every method: EBGEOMETRY_HOST_
DEVICE for pure m_base/PODVector resolution (getVertex/getEdge/getFace, numX,
boundView, setSearchAlgorithm), EBGEOMETRY_HOST for anything touching a Pool,
returning a host container, or delegating to a VertexT/EdgeT/FaceT method --
none of which are themselves device-annotated yet, so calling them from a
device-side function isn't valid until that follow-up lands.
Also rewrites the class-level doc comment and a couple of others that
referenced this class's previous (pre-PR) designs ("unlike earlier revisions
of this class...", "...used to cache") to just describe current behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
…ations Extends the EBGEOMETRY_HOST/EBGEOMETRY_HOST_DEVICE annotation pass (already applied to MeshT) to the rest of the DCEL value types, using the same classification: HOST_DEVICE for anything resolving purely through plain values/an explicit Mesh&; HOST for anything touching a Pool, allocating a std::vector, or logging to std::cerr. EdgeT and EdgeIteratorT turned out to be fully HOST_DEVICE-eligible; FaceT and VertexT are a mix. Also promotes several of MeshT's own methods from HOST to HOST_DEVICE now that the VertexT/EdgeT/FaceT methods they delegate to are annotated. FaceT::computeWindingNumber's local lambda is inlined, since a lambda defined inside a __host__ __device__ function requires nvcc's --extended-lambda (not currently enabled in this build). Adds a device-SECTION compile test (TestDCEL.cpp, guarded by EBGEOMETRY_CUDA/EBGEOMETRY_HIP) exercising the annotated DCEL query surface end to end -- mesh/vertex/edge/face accessors, EdgeIteratorT, and signedDistance/unsignedDistance2 -- via Pool::mirror to a device buffer, and registers TestDCEL in the EBGEOMETRY_GPU_TESTS CMake list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
|
@claude review Generated by Claude Code |
|
Claude finished @rmrsk's task in 5m 40s —— View job Claude's review of PR #140Tasks
SummaryThis second pass only reviews what changed since my last comment: the previously-flagged I went through this annotation work function-by-function rather than spot-checking:
No issues found in this increment. |
CI's pull_request trigger was gated to branches: main, so it never fired on any PR in this DCEL/GPU-port stack (all of which target dev) -- explaining why every PR here has shown 0 check runs. Widen it to [main, dev]. The GPU-CUDA/GPU-HIP jobs previously only built the device-guarded test binaries (no GPU needed to compile). Add a step to each that runs them too: Tests/CMakeLists.txt now re-registers each GPU binary's [gpu]-tagged Catch2 cases under a "gpu-device" CTest label (without duplicating the device-test list in the workflow YAML), so CI can `ctest -L gpu-device`. On these GPU-less runners deviceAvailable() is false and every case SKIP()s cleanly, but running them still verifies the host-side setup and the SKIP() path itself execute correctly, not just that they compile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
Source/EBGeometry_MemoryResource.hpp:408 uses "HSA" (AMD's Heterogeneous System Architecture, the unified-virtual-addressing mechanism MappedMemoryResource's doc comment references for HIP), which codespell flags as a typo for "HAS". This was never caught before now because CI's pull_request trigger only fired on branches: main, so it never ran on any PR in this stack -- the Codespell job is the first thing to actually execute against this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
…EdgeT::unsignedDistance2 Now that CI's pull_request trigger actually fires on this branch (previous commit), the GPU-HIP job caught a real bug: EdgeT::unsignedDistance2 calls std::clamp(), which under libstdc++'s hardened mode expands to an assertion that calls a __host__-only function. HIP's clang device compiler rejects that from a __host__ __device__ function (nvcc only warns, which is why GPU-CUDA passed). Vec3T::clamp() already avoids this with a hand-rolled ternary implementation for the same reason -- mirrored here for the scalar case. Swept the rest of the codebase's std::clamp call sites; this was the only one reachable from EBGEOMETRY_HOST_DEVICE code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
An adversarial review of the DCEL GPU annotation work (dcel-mesh-explicit-base) found five issues against the requirement that the entire DCEL functionality be copyable to device and queryable there. All five addressed: 1. MeshT had no public device-callable *signed* distance query -- every signedDistance() overload was EBGEOMETRY_HOST-only because its algorithm-dispatch switch logged to std::cerr on a corrupted enum value, while the actual device-safe work (DirectSignedDistance/DirectSignedDistance2) sat behind `protected`, unreachable from a kernel. Removed the std::cerr call (EBGEOMETRY_EXPECT(false) remains the sole diagnostic, consistent with this codebase's assertions-are-primary convention) and promoted all four public signedDistance() overloads to EBGEOMETRY_HOST_DEVICE. 2. VertexT/EdgeT/FaceT had no static_assert guarding an arbitrary user Meta for trivial copyability -- unlike PointAoSoA/TriangleAoSoA, which already assert std::is_trivially_copyable_v<Meta> in-class. A user Meta containing e.g. a std::string would silently break the "safe to mirror" invariant. Added the same guard to all three. 3. EdgeIteratorT was documented as not *needing* to be trivially copyable but was, in fact, trivially copyable and unguarded -- nothing stopped it being stored in a PODVector and mirrored to device, carrying a dangling host m_mesh pointer. Gave it a user-provided (non-defaulted) copy constructor with identical memberwise-copy semantics, which makes std::is_trivially_copyable_v false and therefore fails PODVector's own static_assert at the point of misuse; added a static_assert locking this in. 4. MeshT::boundView()'s safety (never mutate through a view built from a const void* base) was convention-only. Changed its return type to `const Mesh`, which at least blocks a chained mutating call directly on the returned value, and documented that callers must still bind the result as const. 5. Expanded the [gpu] device test to exercise flip(), setInsideOutsideAlgorithm (WindingNumber/SubtendedAngle branches), and the newly-public device signedDistance() -- previously untested at the device-kernel level. reconcileEdges is deliberately left untested here: it is `protected`, consistent with this PR's established building-is-host-only philosophy (reconciling is a build/mutate step, not a query), so exposing it publicly for testability would be a separate, more debatable API change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142n7QLFLTVsXJKJ8hcx3DS
|
@claude review Generated by Claude Code |
|
Claude finished @rmrsk's task in 2m 26s —— View job Claude's review of PR #140 (third pass)Tasks
(No network/build access in this run either — this is a careful read-through, not a compiled/executed verification, same caveat as both prior passes on this PR.) SummaryThis pass only covers what changed since my last comment. The previously-flagged
No issues found in this increment — the fixes are correctly targeted and match what they claim to do. |
Summary
Background
This is PR 4 of the GPU-port DCEL migration, stacked on top of #139 (Pool-backed
PODVectorstorage). #139 gave
MeshTaPool*cached at construction and dereferenced from every query --correct on the host, but fundamentally unsafe for the eventual device-mirror goal: a pointer
cached in one address space is meaningless once the same bytes are mirrored into another (and
Pool::mirror()'s destinationPoolobject is itself still host-resident even when its blockis device memory), so
MeshTcould never actually be copied to a device and remain usable.Solution
MeshTno longer stores aPool*. It is now default-constructible and stores only a resolvedvoid* m_base, set once via the newbind(const Pool&)(asserts the Pool is frozen).form (e.g.
getVertex(void* a_base, uint32_t), valid at any point including mid-build, before aPool is frozen) and a no-argument, bound form (e.g.
getVertex(uint32_t)) that resolvesagainst
m_basefor convenient querying once building is finished. This mirrors thePODVector::at()-vs-bind()convention already established in the memory foundation.Build-phase mutators (
reserveVertices/Edges/Faces,addVertex/Edge/Face) takePool&explicitly, matching
PODVector::reserveFrom/push_back.reconcile()/sanityCheck()/the direct signed-distance queries delegate toFaceT/EdgeT/VertexTmethods that take aconst Mesh&and always resolve via the mesh'sbound accessors -- unusable pre-bind. Rather than adding base parameters to those classes'
public interfaces (which would leak memory-semantics concerns into types that shouldn't need
them),
MeshTgained a smallboundView(const void* a_base)helper: a disposable, stack-only,trivially-copyable view of the same mesh data bound to an explicit base, safe to pass wherever a
bound
const Mesh&is required before the real mesh can be bound.Soup::soupToDCEL/reconcilePairEdgesDCEL, theSTL/PLY/OBJ/VTKconvertToDCELentrypoints, and
Parser::readIntoDCELbuild purely against an explicitPool&and never freeze it-- the Pool may still be shared with more files/meshes.
FlatMeshSDF/MeshSDF's constructors are the actual freeze+bind boundary: retaining a mesh forlong-term querying is the point at which building must be considered finished, so their
constructors now take
Pool&and freeze (idempotent) + bind it.TriMeshSDF's mesh-basedconstructor deliberately does neither -- it only reads the mesh once to extract flat triangles
and does not retain it, so forcing a freeze would needlessly block any other mesh still being
built into a shared Pool.
Parser's multi-filereadIntoMesh/readIntoPackedBVHnow build every file's mesh beforewrapping any of them (rather than looping the single-file overload), since wrapping the first
file would otherwise freeze the shared Pool out from under the rest.
Pool/PODVector/MemoryResourceingeneral, and a matching "Memory model" subsection on
ImplemDCEL.rstfor howMeshTis builton top of it specifically, including the sharing/freezing pitfalls this change introduces.
Updates
Parsers.rstfor the newsoupToDCELsignature and the freeze/pool-sharing caveats.Side-effects
MeshT's constructor,Soup::soupToDCEL, orFlatMeshSDF/MeshSDF/TriMeshSDF's mesh-based constructors directly:MeshTis nowdefault-constructed (no
Poolat construction);soupToDCELtakes an explicitPool&;FlatMeshSDF/MeshSDF/TriMeshSDF's mesh-based constructors take an additionalPool&.Parser::readInto*'s own public signatures ((filename, pool, ...)) are unchanged.Poolshared across multipleFlatMeshSDF/MeshSDFbuilds must have all of its meshesbuilt before the first one is wrapped (documented as a new warning in the Memory model docs);
Examples/MeshSDFpreviously built three independent representations from one sharedPoolandneeded a genuine fix (three separate
Pools) rather than a mechanical signature update.validated by the full unit test suite (double and float), all Examples via
ctest, and thedebug-san(ASan/UBSan) preset on every DCEL-related test binary -- all clean.Alternative solutions
MeshT::reconcile()/sanityCheck()working by inliningFaceT/EdgeT/VertexT's topology-walking and geometry-computation logic directly intoMeshT, avoiding theboundView()helper entirely. Rejected: it would duplicate real logic that already livescorrectly on those classes, for no benefit over a small, clearly-scoped, disposable stack-local
view.
Poolautomatically insideParser::readIntoDCELitself (so a bare parsedmesh is always immediately queryable). Rejected:
readIntoDCEL's own multi-file overloaddepends on the ability to keep building into a still-open
Poolacross several files, which anautomatic freeze on the first file would break; freeze+bind is deferred to the SDF-wrapper layer
instead, where "done building" is unambiguous.
Reviewer checklist (to be completed by a human)
@claude review.Generated by Claude Code