Skip to content

Collapse star reuse - #9590

Open
IasonManolas wants to merge 9 commits into
CGAL:mainfrom
IasonManolas:mr/collapse-star-reuse
Open

Collapse star reuse#9590
IasonManolas wants to merge 9 commits into
CGAL:mainfrom
IasonManolas:mr/collapse-star-reuse

Conversation

@IasonManolas

@IasonManolas IasonManolas commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Profiling the collapse pass found four independent costs, all coming from re-walking or
re-collecting the same handful of cells and vertices around a candidate collapse edge. Four
commits:

  • memoize surface_patch_index over the collapse scan: can_be_collapsed() calls
    surface_patch_index(v) for both endpoints of every non-boundary edge, and it walks a
    vertex's whole incident-facet star with no early exit. collapse_short_edges()'s initial
    scan over all finite edges reaches the same vertex once per incident edge, so its star gets
    walked as many times as it has edges. The scan does not modify the mesh, so a cache scoped to
    the scan replaces the repeats with a hash lookup after the first. The cache is opt-in via a
    pointer defaulting to null, since can_be_collapsed() is also called after individual
    collapses (when the cache would be stale) to re-evaluate newly-incident edges.
  • settle a collapse's ring test once for all three attempts: collapse_edge() calls
    is_valid_collapse(edge, type, new_pos, c3t3), which walks the star of v0 and/or v1 into
    a heap vector, runs an orientation predicate per cell, and then runs a ring test over the
    cells around the edge. On failure with TO_MIDPOINT the whole thing repeats for TO_V0 and
    again for TO_V1. Further down, the manifold test and CollapseTriangulation build
    cells_to_insert from those same two stars a fourth time. The ring test depends on neither
    the collapse type nor the new position, so it is hoisted ahead of the orientation cascade (it
    is also the cheap one: one cell circulation, no allocation, no orientation predicate), and
    each star is now collected once, lazily, into a small_vector and handed to every attempt and
    to cells_to_insert.
  • read incident_subdomains straight off the star walk: incident_subdomains(v) collected
    the whole incident-cell star into a heap vector and then read nothing from it but
    subdomain_index(). Emitting straight to the output iterator as the star is walked removes
    one heap allocation per call on the hottest star-walk site in the profile
    (nb_incident_subdomains <- topology_test <- collapse_edge).
  • nb_incident_subdomains(v, c3t3) > 1 without counting the whole star: the count walks
    every cell around v to build a set of distinct subdomain indices, and topology_test's only
    use of it is the > 1 comparison. A traversal that stops at the second distinct index -
    reusing the TDS's own conflict-mark flags for the walk, the same traversal
    TDS_3::incident_cells_3() performs - answers the same question after a handful of cells
    instead of the whole star.

Testing

Verdict equivalence. All four changes are pure reorderings, caches, or streaming
equivalents over read-only predicates; none change which collapse is ultimately selected or in
what order. Full test suite (ctest, all executables in test/Tetrahedral_remeshing) passes
with all four commits applied.

Byte-identical output, all four commits applied to cgal/main:

mesh tets byte-identical
bear ~90K YES
bunny00 ~260K YES
101556.mesh (Thingi10K) 3,521,237 YES

Performance. perf stat -e instructions, Linux, Release, sequential, against plain
cgal/main.

mesh iterations cgal/main instr this PR Δ instr this PR wall
bear 10 67.01 G 64.66 G -3.51% 23.06 s
bunny00 10 148.80 G 142.08 G -4.52% 66.80 s
101556.mesh 3 2441.77 G 2349.47 G -3.78% 1183.75 s

Release Management

  • Affected package(s): Tetrahedral_remeshing
  • License and copyright ownership: unchanged

…scan

surface_patch_index(v) walks v's whole incident-facet star with no early
exit. can_be_collapsed() calls it for both endpoints of every non-boundary
edge, and collapse_short_edges()'s initial scan over all finite edges
reaches the same vertex once per incident edge, so its star gets walked as
many times as it has edges.

The scan does not modify the mesh, so the first answer for a vertex stays
valid for the rest of it: a cache scoped to the scan replaces the repeated
walks with a hash lookup after the first. The cache is opt-in via a
pointer defaulting to null, since can_be_collapsed() is also called after
individual collapses (when the cache would be stale) to re-evaluate
newly-incident edges.

Byte-identical: same values, same order, so the same edges enter the
bimap in the same sequence.
…ee attempts

collapse_edge() calls is_valid_collapse(edge, type, new_pos, c3t3), which
walks the star of v0 and/or v1 into a heap vector, runs an orientation
predicate per cell, and then runs a ring test over the cells around the
edge. On failure with TO_MIDPOINT the whole thing repeats for TO_V0 and
again for TO_V1. Further down, the angle and manifold tests build
cells_to_insert from those same two stars a fourth time.

The ring test depends on neither the collapse type nor the new position,
so it decides all three attempts at once: it is hoisted ahead of the
orientation cascade, since it is also the cheap one (one cell circulation,
no allocation, no orientation predicate).

Each star is now collected once, lazily, into a small_vector and handed to
every attempt and to cells_to_insert. cells_to_insert is still filled one
handle at a time in the original walk order, so its iteration order is
unchanged.

Byte-identical: same predicates, same order of evaluation, only the
redundant walks are removed.
… walk

incident_subdomains(v) collected the whole incident-cell star into a heap
vector and then read nothing from it but subdomain_index(). Emitting
straight to the output iterator as the star is walked removes one heap
allocation per call on the hottest star-walk site in the profile
(nb_incident_subdomains <- topology_test <- collapse_edge).

Identical emission order, so byte-identical.
…ound a vertex

nb_incident_subdomains(v, c3t3) > 1 asks for a count when it only needs
"is there a second index". The count walks every cell around v to build a
set of distinct subdomain indices; the question only needs the walk to
reach a second index, which on a vertex lying on a surface happens after
a handful of cells rather than the whole star.

has_several_incident_subdomains() reuses the TDS's own conflict-mark
traversal - the same one TDS_3::incident_cells_3() performs - and stops as
soon as a second index appears. Same boolean, so byte-identical by
construction.

The marks are shared state, so the function must not be called from
inside another marking traversal; the collapse guard chain that calls it
is not.
IasonManolas and others added 5 commits August 6, 2026 16:24
…nal/collapse_short_edges.h


using Geom_traits::Point_3 instead of Point

Co-authored-by: Jane Tournois <janetournois@users.noreply.github.com>
…nal/collapse_short_edges.h


removed redundant point()

Co-authored-by: Jane Tournois <janetournois@users.noreply.github.com>
…nal/collapse_short_edges.h

Co-authored-by: Jane Tournois <janetournois@users.noreply.github.com>
…nal/collapse_short_edges.h

Co-authored-by: Jane Tournois <janetournois@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants