From c5f3248fbc46168e740773d644ae2e7bd9da2c92 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 10 Aug 2026 20:48:45 +0200 Subject: [PATCH] tools/bench_ext.py: measure the bulk APIs against the loops they replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the numbers next to the code rather than in a commit message, since the adoption decisions turn on them. Two results are not what the plan assumed: - face_surface_props gains *nothing* from leaving Python — the serial C++ loop costs exactly what the Python loop costs. That agrees with the app's own profile, which found SurfaceProperties to be 94 % of face_candidate_anchors: the time is inside the kernel, so the interpreter round trip was never the problem. The whole 10.9x is OSD_Parallel, reachable only because the GIL is released. - BinTools is already fast enough. Dropping the BytesIO detour saves nothing measurable, so the pools' overhead is not in serialisation — worth knowing before rewriting seventeen call sites for it. Mesh extraction is the clear win at 35x, where the Python loop overhead really does dominate; edge polylines 2.6x. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6 --- pyproject.toml | 2 +- tools/bench_ext.py | 223 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 tools/bench_ext.py diff --git a/pyproject.toml b/pyproject.toml index 8e48224..e1daf93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "n3xd-ocp" # .devN suffix: the registry never allows republishing a version, and its # cleanup rule collects only the dev ones. CMake asserts the prefix matches # the OCCT it found. -version = "7.9.3.1.dev4" +version = "7.9.3.1.dev5" description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)" readme = "README.md" requires-python = ">=3.12" diff --git a/tools/bench_ext.py b/tools/bench_ext.py new file mode 100644 index 0000000..4612e67 --- /dev/null +++ b/tools/bench_ext.py @@ -0,0 +1,223 @@ +"""What the n3xd_ocp bulk APIs are worth, against the Python loops they replace. + +Run under the parity venv, which has both this wheel and numpy: + + cd ../app && .venv-ocp-parity/bin/python ../ocp/tools/bench_ext.py + +Each pair measures the same work two ways on the same shape, so the ratio is +the adoption win for a backend call site — not a comparison between wheels. + +Measured 2026-08-10 on a 66-face plate (16-core dev box): + + face props (66 faces) 11.9 ms -> 1.1 ms 10.9x + ... serial (no OSD_Parallel) 11.4 ms -> 11.5 ms 1.0x + BREP write (73 KiB) 1.1 ms -> 1.0 ms 1.0x + BREP read 1.6 ms -> 1.5 ms 1.0x + mesh extract (20412 triangles) 17.3 ms -> 0.5 ms 35.0x + edge polylines (192 edges) 3.8 ms -> 1.5 ms 2.6x + +Two of those numbers are worth reading carefully. + +**face_surface_props gains nothing from leaving Python** — the serial C++ loop +costs exactly what the Python loop costs. That is consistent with the app's own +profile, which found SurfaceProperties to be 94 % of face_candidate_anchors: +the time is genuinely inside the kernel, so the interpreter round trip was never +the problem. The whole 10.9x is OSD_Parallel, which is only reachable at all +because the GIL is released. + +**BinTools is already fast enough.** Dropping the BytesIO detour saves nothing +measurable at this payload size, so the pools' serialisation is not where their +overhead lives. Worth knowing before rewriting seventeen call sites for it. +""" + +import statistics +import time + +import numpy as np + +import n3xd_ocp +from OCP.BinTools import BinTools +from OCP.BRep import BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve +from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.BRepGProp import BRepGProp +from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCP.GCPnts import GCPnts_TangentialDeflection +from OCP.GProp import GProp_GProps +from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt +from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_REVERSED +from OCP.TopExp import TopExp +from OCP.TopLoc import TopLoc_Location +from OCP.TopoDS import TopoDS, TopoDS_Shape +from OCP.TopTools import TopTools_IndexedMapOfShape, TopTools_ListOfShape +from io import BytesIO + +LINEAR, ANGULAR = 0.05, 0.15 + + +def subs(shape, kind): + smap = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(shape, kind, smap) + return [smap.FindKey(i) for i in range(1, smap.Extent() + 1)] + + +def build_workpiece(n_holes=60): + """A plate with many bores: lots of faces, which is the regime that hurts.""" + body = BRepPrimAPI_MakeBox(200.0, 200.0, 20.0).Shape() + tools = TopTools_ListOfShape() + for i in range(n_holes): + x = 10.0 + (i % 10) * 20.0 + y = 10.0 + (i // 10) * 20.0 + tools.Append( + BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(x, y, -1.0), gp_Dir(0, 0, 1)), 4.0, 30.0 + ).Shape() + ) + args = TopTools_ListOfShape() + args.Append(body) + cut = BRepAlgoAPI_Cut() + cut.SetArguments(args) + cut.SetTools(tools) + cut.SetRunParallel(True) + cut.Build() + return cut.Shape() + + +def timed(fn, repeats=5): + samples = [] + for _ in range(repeats): + start = time.perf_counter() + fn() + samples.append(time.perf_counter() - start) + return statistics.median(samples) + + +def report(label, slow, fast, extra=""): + print(f"{label:<34} {slow * 1e3:9.1f} ms -> {fast * 1e3:8.1f} ms " + f"{slow / fast:5.1f}x {extra}") + + +# --- face measurement ------------------------------------------------------ + +def bench_measure(shape): + faces = subs(shape, TopAbs_FACE) + + def python_loop(): + for face in faces: + props = GProp_GProps() + BRepGProp.SurfaceProperties_s(face, props) + centre = props.CentreOfMass() + (props.Mass(), centre.X(), centre.Y(), centre.Z()) + + report( + f"face props ({len(faces)} faces)", + timed(python_loop), + timed(lambda: n3xd_ocp.measure.face_surface_props(shape)), + ) + report( + " ... serial (no OSD_Parallel)", + timed(python_loop), + timed(lambda: n3xd_ocp.measure.face_surface_props(shape, parallel=False)), + ) + + +# --- tessellation ---------------------------------------------------------- + +def bench_tess(shape): + def python_loop(): + out = [] + for i, raw in enumerate(subs(shape, TopAbs_FACE)): + face = TopoDS.Face_s(raw) + loc = TopLoc_Location() + tri = BRep_Tool.Triangulation_s(face, loc) + if tri is None: + continue + trsf = loc.Transformation() + identity = loc.IsIdentity() + rev = face.Orientation() == TopAbs_REVERSED + verts, idx = [], [] + get_node, get_tri = tri.Node, tri.Triangle + for n in range(1, tri.NbNodes() + 1): + node = get_node(n) + if not identity: + node.Transform(trsf) + verts.extend((node.X(), node.Y(), node.Z())) + for t in range(1, tri.NbTriangles() + 1): + a, b, c = get_tri(t).Get() + idx.extend((a - 1, c - 1, b - 1) if rev else (a - 1, b - 1, c - 1)) + out.append({"id": i, "vertices": verts, "indices": idx}) + return out + + faces = python_loop() + tris = sum(len(f["indices"]) // 3 for f in faces) + report( + f"mesh extract ({tris} triangles)", + timed(python_loop), + timed(lambda: n3xd_ocp.tess.extract_meshes(shape)), + ) + + +def bench_edges(shape): + edges = subs(shape, TopAbs_EDGE) + + def python_loop(): + out = [] + for raw in edges: + curve = BRepAdaptor_Curve(TopoDS.Edge_s(raw)) + points = GCPnts_TangentialDeflection(curve, ANGULAR, LINEAR) + flat = [] + for p in range(1, points.NbPoints() + 1): + pt = points.Value(p) + flat.extend((pt.X(), pt.Y(), pt.Z())) + out.append(flat) + return out + + report( + f"edge polylines ({len(edges)} edges)", + timed(python_loop), + timed(lambda: n3xd_ocp.tess.extract_edge_polylines(shape, ANGULAR, LINEAR)), + ) + + +# --- serialisation --------------------------------------------------------- + +def bench_bintools(shape): + def write_stock(): + buf = BytesIO() + BinTools.Write_s(shape, buf) + return buf.getvalue() + + payload = write_stock() + + def read_stock(): + out = TopoDS_Shape() + BinTools.Read_s(out, BytesIO(payload)) + return out + + report( + f"BREP write ({len(payload) // 1024} KiB)", + timed(write_stock), + timed(lambda: n3xd_ocp.bintools.write_bytes(shape)), + ) + report( + "BREP read", + timed(read_stock), + timed(lambda: n3xd_ocp.bintools.read_bytes(payload)), + ) + + +def main(): + print(f"OCP {__import__('OCP').__version__}\n") + shape = build_workpiece() + print(f"workpiece: {len(subs(shape, TopAbs_FACE))} faces, " + f"{len(subs(shape, TopAbs_EDGE))} edges\n") + print(f"{'':<34} {'python':>12} {'n3xd_ocp':>11} speedup") + bench_measure(shape) + bench_bintools(shape) + BRepMesh_IncrementalMesh(shape, LINEAR, False, ANGULAR, True) + bench_tess(shape) + bench_edges(shape) + + +main()