tools/bench_ext.py: measure the bulk APIs against the loops they replace

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
2026-08-10 20:48:45 +02:00
parent 8255fb949c
commit c5f3248fbc
2 changed files with 224 additions and 1 deletions

View File

@@ -9,7 +9,7 @@ name = "n3xd-ocp"
# .devN suffix: the registry never allows republishing a version, and its # .devN suffix: the registry never allows republishing a version, and its
# cleanup rule collects only the dev ones. CMake asserts the prefix matches # cleanup rule collects only the dev ones. CMake asserts the prefix matches
# the OCCT it found. # 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)" description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

223
tools/bench_ext.py Normal file
View File

@@ -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()