Skip to content

Commit 7d14123

Browse files
sawenzelclaude
andcommitted
Mesh the CAD volumes in parallel and show progress
This makes o2-cad-to-tgeo mesh one volume per process and adds a self-test. - Meshing was 77% of the conversion time of a large model and ran on one core. - The volumes are now queued during the tree walk and meshed afterwards, over all cores by default; --jobs sets the count and --jobs 1 keeps the serial path. - A progress line reports how many volumes are done, with an ETA. - The ALICE 3 outer disc converts in 33 s instead of 3m14s, with byte-identical facet files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 83c8d92 commit 7d14123

2 files changed

Lines changed: 118 additions & 4 deletions

File tree

‎Detectors/CADSupport/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ and falls through elsewhere. `required` stops with a report if any leaf cannot u
6363
0.1. `--mesh-solid tgeo` emits ROOT's `TGeoTessellated`, which does not implement navigation;
6464
use it only for a macro that must load outside O2.
6565

66+
Meshing dominates the conversion of a large model, so the volumes are meshed one per process,
67+
over all cores. `--jobs N` sets how many; `--jobs 1` meshes serially in this process. The
68+
triangles are the same either way. A script that imports this module and asks for several jobs
69+
needs the usual `if __name__ == "__main__":` guard.
70+
6671
The output folder holds:
6772

6873
- `geom.C`;

‎Detectors/CADSupport/tools/O2_CADtoTGeo.py‎

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@
3232
import csv
3333
import json
3434
import math
35+
import multiprocessing
36+
import os
3537
import random
3638
import re
3739
import struct
3840
import sys
41+
import time
3942
from array import array
4043
from collections import Counter
4144
from dataclasses import dataclass
@@ -256,11 +259,12 @@ def triangulate_asbbox(shape, scale_to_cm: float = 1.0):
256259
return tris * scale_to_cm if scale_to_cm != 1.0 else tris
257260

258261

259-
def triangulate_CAD_solid(my_solid, meshparam, scale_to_cm: float = 1.0):
262+
def triangulate_CAD_solid(my_solid, meshparam, scale_to_cm: float = 1.0,
263+
in_parallel: bool = True):
260264
lin_defl = float(meshparam.get("lin_defl", 0.1))
261265
ang_defl = float(meshparam.get("ang_defl", 0.1))
262266

263-
BRepMesh_IncrementalMesh(my_solid, lin_defl, False, ang_defl, True)
267+
BRepMesh_IncrementalMesh(my_solid, lin_defl, False, ang_defl, in_parallel)
264268

265269
chunks = []
266270
for face in TopologyExplorer(my_solid).faces():
@@ -2456,6 +2460,39 @@ def convert(mode):
24562460
return tally.failures
24572461

24582462

2463+
def run_parallel_mesh_self_test() -> int:
2464+
"""Assert that meshing in worker processes gives exactly the serial triangles.
2465+
2466+
Returns the number of failures; prints one line per check.
2467+
"""
2468+
tally = _Checks()
2469+
report = tally.report
2470+
2471+
print("\nParallel meshing: the same triangles as serial meshing")
2472+
2473+
step = _Path(__file__).resolve().parent.parent / "examples" / "ExcavatorArm.step"
2474+
if not step.exists():
2475+
report(False, "the example STEP file is there", f"missing: {step}")
2476+
return tally.failures
2477+
meshparam = {"do_meshing": True, "lin_defl": 0.1, "ang_defl": 0.1}
2478+
runs = {}
2479+
# Parallel first: a process that has meshed serially falls back, by design.
2480+
for jobs in (2, 1):
2481+
extract_graph(str(step), meshparam=meshparam, scale_to_cm=0.1, jobs=jobs)
2482+
runs[jobs] = dict(logical_volumes)
2483+
serial, parallel = runs[1], runs[2]
2484+
report(set(serial) == set(parallel) and len(serial) > 1,
2485+
"both runs mesh the same volumes", f"{len(serial)} vs {len(parallel)}")
2486+
same = [k for k in serial if k in parallel and np.array_equal(serial[k], parallel[k])]
2487+
report(len(same) == len(serial) and all(len(serial[k]) > 0 for k in serial),
2488+
"and every volume gets identical, non-empty triangles",
2489+
f"{len(same)}/{len(serial)} identical")
2490+
report(not pending_mesh, "nothing is left waiting to be meshed", f"{len(pending_mesh)} pending")
2491+
2492+
print(f"\n{tally.checks} checks, {tally.failures} failure(s)")
2493+
return tally.failures
2494+
2495+
24592496
def _recognized_inner_wall(face, rec) -> Optional[bool]:
24602497
"""Decide, by measurement, which side of a RECOGNIZED quadric is outside the solid.
24612498
@@ -3697,6 +3734,7 @@ def clip_shape_to_box(shape, clip_box: ClipBox, clip_box_shape, local_to_world:
36973734
placements = [] # (parent_def_lid, child_def_lid, gp_Trsf local)
36983735
top_defs = set() # top definition lids
36993736
visited_defs = set() # expanded defs
3737+
pending_mesh: List[str] = [] # def keys waiting for mesh_pending_volumes()
37003738

37013739

37023740
def reset_graph() -> None:
@@ -3710,6 +3748,7 @@ def reset_graph() -> None:
37103748
placements = []
37113749
top_defs = set()
37123750
visited_defs = set()
3751+
pending_mesh.clear()
37133752

37143753

37153754
def cpp_var_for_def(lid: str) -> str:
@@ -3769,11 +3808,72 @@ def _register_leaf_shape(def_key: str, shape, meshparam, scale_to_cm: float,
37693808
def_shapes[def_key] = shape
37703809

37713810
do_meshing = (meshparam is not None) and meshparam.get("do_meshing", None) is True
3772-
logical_volumes[def_key] = (triangulate_CAD_solid(shape, meshparam=meshparam, scale_to_cm=scale_to_cm)
3773-
if do_meshing else triangulate_asbbox(shape, scale_to_cm=scale_to_cm))
3811+
if do_meshing:
3812+
# Meshed after the walk, by mesh_pending_volumes(), possibly in parallel.
3813+
logical_volumes[def_key] = None
3814+
pending_mesh.append(def_key)
3815+
else:
3816+
logical_volumes[def_key] = triangulate_asbbox(shape, scale_to_cm=scale_to_cm)
37743817
return True
37753818

37763819

3820+
_MESH_ARGS: tuple = (None, 1.0)
3821+
_meshed_in_process = False
3822+
3823+
3824+
def _mesh_worker(def_key: str):
3825+
"""Mesh one volume in a forked worker, which inherits the parent's shapes.
3826+
3827+
OCCT's own face-level parallelism is off here: the parts already run in parallel, and nested
3828+
threads would only contend. It gives the same triangles either way.
3829+
"""
3830+
meshparam, scale_to_cm = _MESH_ARGS
3831+
return def_key, triangulate_CAD_solid(def_shapes[def_key], meshparam=meshparam,
3832+
scale_to_cm=scale_to_cm, in_parallel=False)
3833+
3834+
3835+
def _print_progress(label: str, done: int, total: int, t0: float) -> None:
3836+
"""One progress line with an ETA: redrawn in place on a terminal, every 10% in a log."""
3837+
elapsed = time.time() - t0
3838+
eta = elapsed / done * (total - done) if done else 0.0
3839+
line = f"{label}: {done}/{total} ({100.0 * done / total:.0f}%), {elapsed:.0f} s elapsed, ETA {eta:.0f} s"
3840+
if sys.stdout.isatty():
3841+
print("\r" + line, end="\n" if done == total else "", flush=True)
3842+
elif done == total or (done * 10) // total != ((done - 1) * 10) // total:
3843+
print(line, flush=True)
3844+
3845+
3846+
def mesh_pending_volumes(meshparam, scale_to_cm: float, jobs: int = 1) -> None:
3847+
"""Triangulate every volume queued by the walk; in `jobs` worker processes when jobs > 1."""
3848+
keys = list(pending_mesh)
3849+
pending_mesh.clear()
3850+
if not keys:
3851+
return
3852+
global _MESH_ARGS, _meshed_in_process
3853+
t0 = time.time()
3854+
jobs = max(1, min(jobs, len(keys)))
3855+
if jobs > 1 and "fork" not in multiprocessing.get_all_start_methods():
3856+
print(" [WARN] this platform cannot fork: meshing serially in one process.")
3857+
jobs = 1
3858+
if jobs > 1 and _meshed_in_process:
3859+
# Forking a process that already holds OCCT's meshing threads deadlocks.
3860+
print(" [WARN] this process has already meshed serially: meshing serially again.")
3861+
jobs = 1
3862+
label = f"Meshing {len(keys)} volume(s)" + (f" in {jobs} processes" if jobs > 1 else "")
3863+
if jobs == 1:
3864+
_meshed_in_process = True
3865+
for i, key in enumerate(keys):
3866+
logical_volumes[key] = triangulate_CAD_solid(def_shapes[key], meshparam=meshparam,
3867+
scale_to_cm=scale_to_cm)
3868+
_print_progress(label, i + 1, len(keys), t0)
3869+
return
3870+
_MESH_ARGS = (meshparam, scale_to_cm)
3871+
with multiprocessing.get_context("fork").Pool(jobs) as pool:
3872+
for i, (key, tris) in enumerate(pool.imap_unordered(_mesh_worker, keys)):
3873+
logical_volumes[key] = tris
3874+
_print_progress(label, i + 1, len(keys), t0)
3875+
3876+
37773877
def expand_definition(
37783878
def_label: TDF_Label,
37793879
shape_tool,
@@ -4200,6 +4300,7 @@ def extract_graph(
42004300
clip_box: Optional[ClipBox] = None,
42014301
clip_deduplicate: str = "intact",
42024302
name_filter: Optional[NameFilter] = None,
4303+
jobs: int = 1,
42034304
):
42044305
reset_graph()
42054306
doc, shape_tool = load_step_with_xcaf(step_path)
@@ -4211,6 +4312,7 @@ def extract_graph(
42114312
clip_deduplicate=clip_deduplicate,
42124313
name_filter=name_filter,
42134314
)
4315+
mesh_pending_volumes(meshparam, scale_to_cm, jobs=jobs)
42144316
return doc, shape_tool
42154317

42164318

@@ -4372,6 +4474,7 @@ def emit_root_macro(
43724474
max_splits: Optional[int] = None,
43734475
decompose_timeout: Optional[float] = None,
43744476
mesh_solid: str = "o2",
4477+
jobs: int = 1,
43754478
):
43764479
# exact_surfaces mode:
43774480
# off : tessellated output only (default; leaves generated output unchanged).
@@ -4402,6 +4505,7 @@ def emit_root_macro(
44024505
clip_box=clip_box,
44034506
clip_deduplicate=clip_deduplicate,
44044507
name_filter=name_filter,
4508+
jobs=jobs,
44054509
)
44064510

44074511
out_folder = out_folder.expanduser().resolve()
@@ -4843,6 +4947,9 @@ def main():
48434947
ap.add_argument("--mesh", action="store_true", help="Use full BRepMesh triangulation instead of bounding boxes")
48444948
ap.add_argument("--print-tree", action="store_true", help="Just prints the geometry tree")
48454949
ap.add_argument("--mesh-prec", type=float, default=0.1, help="meshing precision. lower --> slower")
4950+
ap.add_argument("--jobs", "-j", type=int, default=os.cpu_count() or 1,
4951+
help="Processes that mesh the volumes with --mesh (default: all cores); 1 meshes "
4952+
"serially in this process")
48464953
ap.add_argument("--in-field", nargs="?", const="2,10", default=None, metavar="IFIELD,FIELDM",
48474954
help="Treat this module as sitting in the magnetic field: write the eight Geant "
48484955
"medium parameters, with ifield and fieldm taken from the live field. "
@@ -4898,6 +5005,7 @@ def main():
48985005
+ run_multibody_leaf_self_test()
48995006
+ run_name_filter_self_test()
49005007
+ run_missing_root_self_test()
5008+
+ run_parallel_mesh_self_test()
49015009
+ run_in_field_media_self_test()
49025010
+ run_bom_token_self_test()) else 0)
49035011
if args.step is None:
@@ -4991,6 +5099,7 @@ def main():
49915099
max_splits=args.max_splits,
49925100
decompose_timeout=args.decompose_timeout,
49935101
mesh_solid=args.mesh_solid,
5102+
jobs=args.jobs,
49945103
)
49955104
out_macro.write_text(code)
49965105

0 commit comments

Comments
 (0)