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:
223
tools/bench_ext.py
Normal file
223
tools/bench_ext.py
Normal 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()
|
||||
Reference in New Issue
Block a user